From ea0c68529b5a1597333a425a9baddd3708c2b7c1 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Thu, 18 Jun 2026 15:06:16 +0200 Subject: [PATCH 001/485] Fix keyboard hiding inputs in new filters using landscape mode --- .../index.native.tsx | 4 +- .../CollapsibleHeaderOnKeyboard/types.ts | 6 + src/components/Modal/BaseModal.tsx | 8 +- src/components/Modal/types.ts | 6 + .../PopoverWithMeasuredContent/index.tsx | 7 +- .../PopoverWithMeasuredContent/types.ts | 3 + .../AdvancedFiltersFullscreen.tsx | 141 ++++++++++++++++++ .../Search/FilterDropdowns/DropdownButton.tsx | 3 + .../FilterDropdowns/FilterPopupButton.tsx | 14 +- .../SearchPageHeader/SearchFilterBar.tsx | 7 + .../SearchAdvancedFiltersContentBase.tsx | 64 +++++++- .../utils/generators/ModalStyleUtils.ts | 6 +- 12 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersFullscreen.tsx diff --git a/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx b/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx index 3a04b6c48e0b..4ad2721b8827 100644 --- a/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx +++ b/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx @@ -30,7 +30,7 @@ function isKeyboardOpeningAtGivenProgress(keyboardProgress: number, prevKeyboard * Intended for landscape mode on phones where the keyboard + header can leave no room for inputs. * Uses height animation (not translateY) so the freed space is reclaimed by the layout below. */ -function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: CollapsibleHeaderOnKeyboardProps) { +function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0, alwaysCollapseHeaderOnKeyboard = false}: CollapsibleHeaderOnKeyboardProps) { const isFocused = useIsFocused(); const prevIsFocused = usePrevious(isFocused); // JS ref guards against re-measurement when the Reanimated.View fires onLayout with height=0 @@ -166,7 +166,7 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co // Target header height: give the input exactly the space it needs above the keyboard, // the header gets what remains. Clamped to [0, naturalHeight]. const keyboardTop = windowHeightValue + keyboardHeight; - const targetHeight = Math.max(0, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get()); + const targetHeight = alwaysCollapseHeaderOnKeyboard ? 0 : Math.max(0, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get()); const naturalHeightValue = naturalHeight.get(); if (targetHeight >= naturalHeightValue) { diff --git a/src/components/CollapsibleHeaderOnKeyboard/types.ts b/src/components/CollapsibleHeaderOnKeyboard/types.ts index d5281b0d2d6e..7734ac5eb062 100644 --- a/src/components/CollapsibleHeaderOnKeyboard/types.ts +++ b/src/components/CollapsibleHeaderOnKeyboard/types.ts @@ -4,6 +4,12 @@ type CollapsibleHeaderOnKeyboardProps = { * component, keyboard, and focused input — e.g. a tab bar below the list. * The collapse target is reduced by this amount so those elements are not counted twice. */ collapsibleHeaderOffset?: number; + + /** + * If true, the header will always collapse on keyboard open, + * regardless if there is enough space for the input above the keyboard. + */ + alwaysCollapseHeaderOnKeyboard?: boolean; }; // eslint-disable-next-line import/prefer-default-export diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index b9bf1acacdc8..ab78f9e8ff93 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -75,6 +75,7 @@ function BaseModal({ shouldDisplayBelowModals = false, shouldKeepRightDockedBackdropInNarrowPane = false, shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode = true, + shouldTakeFullHeightOnKeyboardOpenInLandscapeMode = false, }: BaseModalProps) { // When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode. const isUsingEdgeToEdgeMode = enableEdgeToEdgeBottomSafeAreaPadding !== undefined; @@ -249,6 +250,8 @@ function BaseModal({ shouldDisableBottomSafeAreaPadding: !!shouldDisableBottomSafeAreaPadding, }, shouldDisplayBelowModals, + shouldTakeFullHeightOnKeyboardOpenInLandscapeMode && isInLandscapeMode, + keyboardStateContextValue.isKeyboardActive, ), [ StyleUtils, @@ -264,6 +267,9 @@ function BaseModal({ modalOverlapsWithTopSafeArea, shouldDisableBottomSafeAreaPadding, shouldDisplayBelowModals, + shouldTakeFullHeightOnKeyboardOpenInLandscapeMode, + isInLandscapeMode, + keyboardStateContextValue.isKeyboardActive, ], ); @@ -382,7 +388,7 @@ function BaseModal({ statusBarTranslucent={statusBarTranslucent} navigationBarTranslucent={navigationBarTranslucent} onLayout={onLayout} - avoidKeyboard={avoidKeyboard} + avoidKeyboard={isInLandscapeMode ? !shouldTakeFullHeightOnKeyboardOpenInLandscapeMode : avoidKeyboard} customBackdrop={shouldUseCustomBackdrop ? : undefined} type={type} shouldIgnoreBackHandlerDuringTransition={shouldIgnoreBackHandlerDuringTransition} diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index ab0d0c61c2d9..33d9d7ded8ca 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -138,6 +138,12 @@ type BaseModalProps = Partial & * Defaults to true. */ shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode?: boolean; + + /** + * Whether the modal should take the full height of the screen when keyboard is open in landscape mode. + * Defaults to false. + */ + shouldTakeFullHeightOnKeyboardOpenInLandscapeMode?: boolean; }; export default BaseModalProps; diff --git a/src/components/PopoverWithMeasuredContent/index.tsx b/src/components/PopoverWithMeasuredContent/index.tsx index 9c063dce4982..e85d512a8746 100644 --- a/src/components/PopoverWithMeasuredContent/index.tsx +++ b/src/components/PopoverWithMeasuredContent/index.tsx @@ -11,7 +11,11 @@ import type PopoverWithMeasuredContentProps from './types'; * This component is a perf optimization, it return BOTTOM_DOCKED early, for small screens avoiding Popover measurement logic calculations. * It defers rendering of PopoverWithMeasuredContentBase to idle time to avoid blocking more priority UI updates with measurements. */ -function PopoverWithMeasuredContent({shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode, ...props}: PopoverWithMeasuredContentProps) { +function PopoverWithMeasuredContent({ + shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode, + shouldTakeFullHeightOnKeyboardOpenInLandscapeMode = false, + ...props +}: PopoverWithMeasuredContentProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -36,6 +40,7 @@ function PopoverWithMeasuredContent({shouldWrapModalChildrenInScrollViewIfBottom animationIn="slideInUp" animationOut="slideOutDown" shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode} + shouldTakeFullHeightOnKeyboardOpenInLandscapeMode={shouldTakeFullHeightOnKeyboardOpenInLandscapeMode} /> ); } diff --git a/src/components/PopoverWithMeasuredContent/types.ts b/src/components/PopoverWithMeasuredContent/types.ts index a95e363db036..2c7d1d0d5e14 100644 --- a/src/components/PopoverWithMeasuredContent/types.ts +++ b/src/components/PopoverWithMeasuredContent/types.ts @@ -19,6 +19,9 @@ type PopoverWithMeasuredContentProps = Omit & { /** Whether to skip re-measurement when becoming visible (for components with static dimensions) */ shouldSkipRemeasurement?: boolean; + + /** Whether the dropdown should take the full height of the screen when keyboard is open in landscape mode */ + shouldTakeFullHeightOnKeyboardOpenInLandscapeMode?: boolean; }; export default PopoverWithMeasuredContentProps; diff --git a/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersFullscreen.tsx b/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersFullscreen.tsx new file mode 100644 index 000000000000..4546414b986f --- /dev/null +++ b/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersFullscreen.tsx @@ -0,0 +1,141 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapperContainer from '@components/ScreenWrapper/ScreenWrapperContainer'; +import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; +import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; +import type {SearchQueryJSON} from '@components/Search/types'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getAdvancedFiltersToReset} from '@libs/SearchQueryUtils'; +import {FILTER_VIEW_MAP} from '@libs/SearchUIUtils'; +import type {SearchFilter} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import FilterList from './FilterList'; +import SelectedFilterContent from './SelectedFilterContent'; + +// HeaderWithBackButton in ReportFieldBase uses styles.h10 +const REPORT_FIELD_HEADER_HEIGHT = 40; + +const CONFIRM_BUTTON_HEIGHT = 56; + +type AdvancedFiltersFullscreenProps = { + queryJSON: SearchQueryJSON; + closeOverlay: PopoverComponentProps['closeOverlay']; +}; + +function AdvancedFiltersFullscreen({queryJSON, closeOverlay}: AdvancedFiltersFullscreenProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + + const [selectedFilter, setSelectedFilter] = useState(null); + const [values, setValues] = useState>(searchAdvancedFiltersForm ?? {}); + + const updateFilterQueryParams = useUpdateFilterQuery(queryJSON, true); + + const advancedFiltersToReset = searchAdvancedFiltersForm ? getAdvancedFiltersToReset(searchAdvancedFiltersForm) : undefined; + + const getCollapsibleHeaderOffset = () => { + if (!selectedFilter) { + return undefined; + } + + if (selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD) { + return REPORT_FIELD_HEADER_HEIGHT + CONFIRM_BUTTON_HEIGHT; + } + + if ( + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_CURRENCY || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_AMOUNT || + selectedFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT + ) { + return CONFIRM_BUTTON_HEIGHT; + } + + return undefined; + }; + + return ( + + + { + if (selectedFilter) { + setSelectedFilter(null); + return; + } + closeOverlay(); + }} + /> + + + {!!selectedFilter && ( + + { + setValues((prevValues) => ({...prevValues, ...newValues})); + setSelectedFilter(null); + }} + /> + + )} + {!selectedFilter && ( + <> + {!isEmptyObject(advancedFiltersToReset) && ( + } /> From ee4c182025587b10a511aaa215a76beb89ea7004 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:22:39 +0530 Subject: [PATCH 012/485] Pass NVPs to last accessed report calls --- src/DeepLinkHandler.tsx | 21 ++++++++++++++++++--- src/libs/actions/Link.ts | 5 +++-- src/libs/actions/Report/index.ts | 14 +++++++++----- src/libs/actions/Task.ts | 16 +++++++++++----- src/pages/DynamicReportDetailsPage.tsx | 7 ++++++- tests/actions/TaskTest.ts | 9 +++++---- 6 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index b581317c3e81..facfd2f94baa 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -36,6 +36,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const hasRefetchedPublicRoom = useRef(false); const [allReports, allReportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [session, sessionMetadata] = useOnyx(ONYXKEYS.SESSION); const [conciergeReportID, conciergeReportIDMetadata] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -56,7 +57,9 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { }, []); useEffect(() => { - if (isLoadingOnyxValue(allReportsMetadata, sessionMetadata, conciergeReportIDMetadata, introSelectedMetadata, isSelfTourViewedMetadata, betasMetadata)) { + if ( + isLoadingOnyxValue(allReportsMetadata, reportNameValuePairsMetadata, sessionMetadata, conciergeReportIDMetadata, introSelectedMetadata, isSelfTourViewedMetadata, betasMetadata) + ) { return; } @@ -113,6 +116,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { isSelfTourViewed, betas, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + reportNameValuePairs, ); trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated); } else { @@ -147,7 +151,17 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { if (hasSecureLinkKey(state.url)) { onInitialUrl(state.url as Route); } - openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas, session?.accountID ?? CONST.DEFAULT_NUMBER_ID); + openReportFromDeepLink( + state.url, + allReports, + isCurrentlyAuthenticated, + conciergeReportID, + introSelected, + isSelfTourViewed, + betas, + session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + reportNameValuePairs, + ); trackPendingPublicRoomFromDeepLink(state.url, isCurrentlyAuthenticated); }); @@ -156,12 +170,13 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { clearTimeout(timeoutId); linkingChangeListener.current?.remove(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excluding allReports, isAuthenticated, and onInitialUrl to avoid re-triggering deep link handling on every report update + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excluding allReports, reportNameValuePairs, isAuthenticated, and onInitialUrl to avoid re-triggering deep link handling on every report update }, [ conciergeReportID, introSelected, betas, allReportsMetadata.status, + reportNameValuePairsMetadata.status, sessionMetadata.status, conciergeReportIDMetadata.status, introSelectedMetadata.status, diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 21bd56d06c86..daafb1ce36c9 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -30,7 +30,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {hasCompletedGuidedSetupFlowSelector} from '@src/selectors/Onboarding'; -import type {Beta, IntroSelected, Report} from '@src/types/onyx'; +import type {Beta, IntroSelected, Report, ReportNameValuePairs} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -460,6 +460,7 @@ function openReportFromDeepLink( isSelfTourViewed: boolean | undefined, betas: OnyxEntry, callerAccountID: number, + reportNameValuePairs: OnyxCollection, ) { const reportID = getReportIDFromLink(url); @@ -603,7 +604,7 @@ function openReportFromDeepLink( const report = reportParam ?? reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; // If the report does not exist, navigate to the last accessed report or Concierge chat if (reportID && (!report?.reportID || report.errorFields?.notFound)) { - const lastAccessedReportID = findLastAccessedReport(false, shouldOpenOnAdminRoom(), reportID)?.reportID; + const lastAccessedReportID = findLastAccessedReport(false, shouldOpenOnAdminRoom(), reportID, reportNameValuePairs)?.reportID; if (lastAccessedReportID) { const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID); Navigation.navigate(lastAccessedReportRoute, {forceReplace: Navigation.getTopmostReportId() === reportID, waitForTransition: true}); diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index ca9180724a36..1ab0ac387c80 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -241,6 +241,7 @@ import type { Report, ReportAction, ReportAttributesDerivedValue, + ReportNameValuePairs, ReportUserIsTyping, SidePanelContext, Transaction, @@ -5006,8 +5007,9 @@ function navigateToMostRecentReport( introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, betas: OnyxEntry, + reportNameValuePairs?: OnyxCollection, ) { - const lastAccessedReportID = findLastAccessedReport(false, false, currentReport?.reportID)?.reportID; + const lastAccessedReportID = findLastAccessedReport(false, false, currentReport?.reportID, reportNameValuePairs)?.reportID; if (lastAccessedReportID) { // Check if route exists for super wide RHP vs regular full screen report @@ -5046,8 +5048,8 @@ function getSearchThreadLeaveRoute(report: Report, activeRoute: string): Route | }); } -function getMostRecentReportID(currentReport: OnyxEntry, conciergeReportID: string | undefined) { - const lastAccessedReportID = findLastAccessedReport(false, false, currentReport?.reportID)?.reportID; +function getMostRecentReportID(currentReport: OnyxEntry, conciergeReportID: string | undefined, reportNameValuePairs?: OnyxCollection) { + const lastAccessedReportID = findLastAccessedReport(false, false, currentReport?.reportID, reportNameValuePairs)?.reportID; return lastAccessedReportID ?? conciergeReportID; } @@ -5073,6 +5075,7 @@ function leaveGroupChat( introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, betas: OnyxEntry, + reportNameValuePairs?: OnyxCollection, ) { const reportID = report.reportID; // Use merge instead of set to avoid deleting the report too quickly, which could cause a brief "not found" page to appear. @@ -5123,7 +5126,7 @@ function leaveGroupChat( if (isSearchTopmostFullScreenRoute()) { Navigation.revealRouteBeforeDismissingModal(getReportRouteForCurrentContext({reportID})); } else { - navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas); + navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas, reportNameValuePairs); } API.write(WRITE_COMMANDS.LEAVE_GROUP_CHAT, {reportID}, {optimisticData, successData, failureData}); } @@ -5137,6 +5140,7 @@ function leaveRoom( isSelfTourViewed: boolean | undefined, betas: OnyxEntry, isWorkspaceMemberLeavingWorkspaceRoom = false, + reportNameValuePairs?: OnyxCollection, ) { const reportID = report.reportID; const isChatThread = isChatThreadReportUtils(report); @@ -5248,7 +5252,7 @@ function leaveRoom( return; } // In other cases, the report is deleted and we should move the user to another report. - navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas); + navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas, reportNameValuePairs); } function buildInviteToRoomOnyxData( diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 97674ebf1492..da02576fe14e 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -33,7 +33,7 @@ import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -90,6 +90,7 @@ type CreateTaskAndNavigateParams = { type DeleteTaskOptions = { ancestors?: ReportUtils.Ancestor[]; shouldNavigateBack?: boolean; + reportNameValuePairs?: OnyxCollection; }; /** @@ -1194,7 +1195,12 @@ function getShareDestination( * @param report - The task report being deleted * @returns The URL to navigate to */ -function getNavigationUrlOnTaskDelete(report: OnyxEntry, conciergeReportID: string | undefined, reportActions: OnyxEntry): string | undefined { +function getNavigationUrlOnTaskDelete( + report: OnyxEntry, + conciergeReportID: string | undefined, + reportActions: OnyxEntry, + reportNameValuePairs?: OnyxCollection, +): string | undefined { if (!report) { return undefined; } @@ -1209,7 +1215,7 @@ function getNavigationUrlOnTaskDelete(report: OnyxEntry, conci } // If no parent report, try to navigate to most recent report - const mostRecentReportID = getMostRecentReportID(report, conciergeReportID); + const mostRecentReportID = getMostRecentReportID(report, conciergeReportID, reportNameValuePairs); if (mostRecentReportID) { return ROUTES.REPORT_WITH_ID.getRoute(mostRecentReportID); } @@ -1230,7 +1236,7 @@ function deleteTask( conciergeReportID: string | undefined, delegateEmail: string | undefined, reportActions: OnyxEntry, - {ancestors = [], shouldNavigateBack = true}: DeleteTaskOptions = {}, + {ancestors = [], shouldNavigateBack = true, reportNameValuePairs}: DeleteTaskOptions = {}, ) { if (!report) { return; @@ -1355,7 +1361,7 @@ function deleteTask( API.write(WRITE_COMMANDS.CANCEL_TASK, parameters, {optimisticData, successData, failureData}); notifyNewAction(report.reportID, undefined, true); - const urlToNavigateBack = shouldNavigateBack ? getNavigationUrlOnTaskDelete(report, conciergeReportID, reportActions) : undefined; + const urlToNavigateBack = shouldNavigateBack ? getNavigationUrlOnTaskDelete(report, conciergeReportID, reportActions, reportNameValuePairs) : undefined; if (urlToNavigateBack) { Navigation.goBack(); return urlToNavigateBack; diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 2eda412e15aa..fb8450046a30 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -216,6 +216,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const hasOutstandingChildTask = useHasOutstandingChildTask(report); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); + const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const {reportActions} = usePaginatedReportActions(report.reportID); @@ -403,12 +404,13 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report introSelected, isSelfTourViewed, betas, + reportNameValuePairs, ); return; } const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin); - leaveRoom(report, currentUserPersonalDetails.accountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom); + leaveRoom(report, currentUserPersonalDetails.accountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom, reportNameValuePairs); }, [ isRootGroupChat, isPolicyEmployee, @@ -420,6 +422,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report introSelected, isSelfTourViewed, betas, + reportNameValuePairs, ]); const showLastMemberLeavingModal = useCallback(async () => { @@ -1049,6 +1052,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report { ancestors, shouldNavigateBack: !taskDeleteBackTo, + reportNameValuePairs, }, ); return; @@ -1105,6 +1109,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report conciergeReportID, delegateEmail, ancestors, + reportNameValuePairs, reportActionsForOriginalReportID, moneyRequestReport, moneyRequestReportActions, diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index 1e78844dbbf9..f120b3a3027b 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -1238,11 +1238,12 @@ describe('actions/Task', () => { it('should return most recent report route when no parentReportID and getMostRecentReportID returns a value', () => { const taskReport = {...getFakeReport(), parentReportID: undefined}; const mostRecentReportID = 'recent_456'; + const reportNameValuePairs = {}; getMostRecentReportIDSpy.mockReturnValue(mostRecentReportID); - const result = getNavigationUrlOnTaskDelete(taskReport, 'concierge_123', undefined); + const result = getNavigationUrlOnTaskDelete(taskReport, 'concierge_123', undefined, reportNameValuePairs); expect(result).toBe(`r/${mostRecentReportID}`); - expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(taskReport, 'concierge_123'); + expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(taskReport, 'concierge_123', reportNameValuePairs); }); it('should pass conciergeReportID to getMostRecentReportID as fallback', () => { @@ -1252,7 +1253,7 @@ describe('actions/Task', () => { const result = getNavigationUrlOnTaskDelete(taskReport, conciergeReportID, undefined); expect(result).toBe(`r/${conciergeReportID}`); - expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(taskReport, conciergeReportID); + expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(taskReport, conciergeReportID, undefined); }); it('should return undefined when no parentReportID, no most recent report, and conciergeReportID is undefined', () => { @@ -1610,7 +1611,7 @@ describe('actions/Task', () => { const result = deleteTask(taskReport, undefined, false, mockCurrentUserAccountID, false, undefined, conciergeReportID, undefined, undefined); expect(result).toBe(`r/${conciergeReportID}`); - expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(taskReport, conciergeReportID); + expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(taskReport, conciergeReportID, undefined); expect(Navigation.goBack).toHaveBeenCalled(); }); From 44d2d8f387d650f0c6c3025760ef8e6c5dd987af Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:05:53 +0530 Subject: [PATCH 013/485] Fix report NVP collection name --- src/pages/DynamicReportDetailsPage.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index fb8450046a30..695e70d6ef00 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -216,7 +216,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const hasOutstandingChildTask = useHasOutstandingChildTask(report); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); - const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [lastAccessedReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const {reportActions} = usePaginatedReportActions(report.reportID); @@ -404,13 +404,22 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report introSelected, isSelfTourViewed, betas, - reportNameValuePairs, + lastAccessedReportNameValuePairs, ); return; } const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin); - leaveRoom(report, currentUserPersonalDetails.accountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom, reportNameValuePairs); + leaveRoom( + report, + currentUserPersonalDetails.accountID, + conciergeReportID, + introSelected, + isSelfTourViewed, + betas, + isWorkspaceMemberLeavingWorkspaceRoom, + lastAccessedReportNameValuePairs, + ); }, [ isRootGroupChat, isPolicyEmployee, @@ -422,7 +431,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report introSelected, isSelfTourViewed, betas, - reportNameValuePairs, + lastAccessedReportNameValuePairs, ]); const showLastMemberLeavingModal = useCallback(async () => { @@ -1052,7 +1061,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report { ancestors, shouldNavigateBack: !taskDeleteBackTo, - reportNameValuePairs, + reportNameValuePairs: lastAccessedReportNameValuePairs, }, ); return; @@ -1109,7 +1118,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report conciergeReportID, delegateEmail, ancestors, - reportNameValuePairs, + lastAccessedReportNameValuePairs, reportActionsForOriginalReportID, moneyRequestReport, moneyRequestReportActions, From 1364c4a7672b915ced779ed830cd0fd6422294ae Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:34:35 +0530 Subject: [PATCH 014/485] Refresh warm-link report NVPs --- src/DeepLinkHandler.tsx | 11 ++++++--- tests/unit/DeepLinkHandlerTest.tsx | 36 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index facfd2f94baa..3af2e2048b49 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -37,6 +37,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const [allReports, allReportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const reportNameValuePairsRef = useRef(reportNameValuePairs); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [session, sessionMetadata] = useOnyx(ONYXKEYS.SESSION); const [conciergeReportID, conciergeReportIDMetadata] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -45,6 +46,10 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const [betas, betasMetadata] = useOnyx(ONYXKEYS.BETAS); const isAuthenticated = useIsAuthenticated(); + useEffect(() => { + reportNameValuePairsRef.current = reportNameValuePairs; + }, [reportNameValuePairs]); + // An anonymous deep link into a public room needs to be re-fetched after OpenApp settles (see the effect // below). Track the pending reportID so both the initial-URL and the url-change paths stay in sync. const trackPendingPublicRoomFromDeepLink = useCallback((url: string, isCurrentlyAuthenticated: boolean) => { @@ -116,7 +121,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { isSelfTourViewed, betas, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - reportNameValuePairs, + reportNameValuePairsRef.current, ); trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated); } else { @@ -160,7 +165,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { isSelfTourViewed, betas, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - reportNameValuePairs, + reportNameValuePairsRef.current, ); trackPendingPublicRoomFromDeepLink(state.url, isCurrentlyAuthenticated); }); @@ -170,7 +175,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { clearTimeout(timeoutId); linkingChangeListener.current?.remove(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excluding allReports, reportNameValuePairs, isAuthenticated, and onInitialUrl to avoid re-triggering deep link handling on every report update + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excluding allReports, isAuthenticated, and onInitialUrl to avoid re-triggering deep link handling on every report update }, [ conciergeReportID, introSelected, diff --git a/tests/unit/DeepLinkHandlerTest.tsx b/tests/unit/DeepLinkHandlerTest.tsx index 77ab38769ca5..fabaf40b877e 100644 --- a/tests/unit/DeepLinkHandlerTest.tsx +++ b/tests/unit/DeepLinkHandlerTest.tsx @@ -1,5 +1,6 @@ import {act, render} from '@testing-library/react-native'; +import {openReportFromDeepLink} from '@libs/actions/Link'; import type * as Link from '@libs/actions/Link'; import * as Report from '@libs/actions/Report'; @@ -118,4 +119,39 @@ describe('DeepLinkHandler', () => { expect(Report.openReport).not.toHaveBeenCalled(); }); + + it('passes the latest report name-value pairs to warm deep links', async () => { + const reportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${PUBLIC_ROOM_ID}` as const; + const archivedAt = '2024-01-01 00:00:00.000'; + await act(async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}, + [ONYXKEYS.IS_LOADING_APP]: false, + [ONYXKEYS.CONCIERGE_REPORT_ID]: '', + [ONYXKEYS.NVP_INTRO_SELECTED]: {}, + [ONYXKEYS.BETAS]: [], + }); + }); + const addEventListenerSpy = jest.spyOn(Linking, 'addEventListener'); + + render(); + await waitForBatchedUpdatesWithAct(); + jest.mocked(openReportFromDeepLink).mockClear(); + + await act(async () => { + await Onyx.set(reportNameValuePairsKey, {private_isArchived: archivedAt}); + }); + await waitForBatchedUpdatesWithAct(); + + const urlChangeHandler = addEventListenerSpy.mock.calls.find(([eventType]) => eventType === 'url')?.[1]; + expect(urlChangeHandler).toBeDefined(); + await act(async () => { + urlChangeHandler?.({url: `new-expensify://r/${PUBLIC_ROOM_ID}`}); + }); + + expect(jest.mocked(openReportFromDeepLink).mock.calls.at(-1)?.[8]).toEqual({ + [reportNameValuePairsKey]: {private_isArchived: archivedAt}, + }); + addEventListenerSpy.mockRestore(); + }); }); From 6a0ab4d097e41af73515d4cfb82a910211624b22 Mon Sep 17 00:00:00 2001 From: daledah Date: Mon, 17 Aug 2026 14:09:26 +0700 Subject: [PATCH 015/485] refactor getDisplayNameForParticipant function to use hiddenTranslation --- src/CONST/index.ts | 1 + .../useReportActionAvatars.ts | 2 +- .../usePreviewMessageAnimation.ts | 4 +- src/components/ReportActionItem/TaskView.tsx | 2 +- .../ListItem/UserSelectionListItem.tsx | 2 +- src/libs/NextStepUtils.ts | 2 +- src/libs/OptionsListUtils/index.ts | 11 +- .../PersonalDetailOptionsListUtils/index.ts | 2 +- src/libs/PersonalDetailsUtils.ts | 8 +- src/libs/ReportNameUtils.ts | 23 ++-- src/libs/ReportUtils.ts | 115 +++++++++++++----- src/libs/SidebarUtils.ts | 4 +- src/libs/SuggestionUtils.ts | 8 +- src/pages/ReportAddApproverPage.tsx | 4 +- src/pages/Search/SearchAddApproverPage.tsx | 4 +- src/pages/ShareCodePage.tsx | 5 +- .../inbox/report/ReportTypingIndicator.tsx | 2 +- .../menuItems/QuickActionMenuItem.tsx | 2 +- src/pages/workspace/WorkspaceMembersPage.tsx | 9 +- tests/unit/QuickActionMenuItemTest.tsx | 2 +- .../ReportAddApproverPageTranslateTest.tsx | 2 +- tests/unit/ReportTypingIndicatorTest.tsx | 4 +- tests/unit/ReportUtilsTest.ts | 12 +- .../SearchAddApproverPageTranslateTest.tsx | 2 +- tests/unit/ShareCodePageTranslateTest.tsx | 2 +- tests/unit/SuggestionUtilsTranslateTest.ts | 6 +- tests/unit/TaskViewTranslateTest.tsx | 2 +- tests/unit/UserSelectionListItemTest.tsx | 2 +- tests/unit/usePreviewMessageAnimationTest.ts | 4 +- .../useReportActionAvatarsTranslateTest.tsx | 2 +- 30 files changed, 166 insertions(+), 84 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 57c91c0435dd..8ea88cd543cc 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2925,6 +2925,7 @@ const CONST = { }, CONCIERGE_DISPLAY_NAME: 'Concierge', + HIDDEN_DISPLAY_NAME: 'Hidden', CONCIERGE_GREETING_ACTION_ID: 'concierge-greeting', INTEGRATION_ENTITY_MAP_TYPES: { diff --git a/src/components/ReportActionAvatars/useReportActionAvatars.ts b/src/components/ReportActionAvatars/useReportActionAvatars.ts index 9f9c7516facf..38642b6fa1ef 100644 --- a/src/components/ReportActionAvatars/useReportActionAvatars.ts +++ b/src/components/ReportActionAvatars/useReportActionAvatars.ts @@ -218,7 +218,7 @@ function useReportActionAvatars({ const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); const {avatar, fallbackIcon, login} = personalDetails?.[delegatePersonalDetails ? delegatePersonalDetails.accountID : accountID] ?? {}; - const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails, formatPhoneNumber, translate}) ?? ''; + const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) ?? ''; const invoiceReport = [iouReport, chatReport, reportChatReport].find((susReport) => isInvoiceReport(susReport) || susReport?.chatType === CONST.REPORT.TYPE.INVOICE); const isNestedInInvoiceReport = !!invoiceReport && !isChatThread(report); const isInvoiceReportActor = isAInvoiceReport && (!actorAccountID || displayAllActors || isAReportPreviewAction); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts index 6d58d8a3fec4..fc5e1e24ea21 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts @@ -128,7 +128,7 @@ function usePreviewMessageAnimation({ accountID: managerID, shouldUseShortForm: true, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }); } @@ -144,7 +144,7 @@ function usePreviewMessageAnimation({ accountID: chatReport?.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }); } return translate(paymentVerb, payerOrApproverName); diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index 107284190694..0dba7c1cd4d5 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -273,7 +273,7 @@ function TaskView({report, parentReport, action}: TaskViewProps) { {report?.managerID ? ( ({ return getDisplayNameForParticipant({ accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }); }, [formatPhoneNumber, item.accountID, translate]); diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 90e7398dd528..8ce067caea0b 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -65,7 +65,7 @@ function buildNextStepMessage( formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], ): string { // Escape actor name to prevent HTML injection since this will be rendered as HTML - const actor = Str.safeEscape(getDisplayNameForParticipant({accountID: nextStep.actorAccountID, formatPhoneNumber, translate}) ?? ''); + const actor = Str.safeEscape(getDisplayNameForParticipant({accountID: nextStep.actorAccountID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) ?? ''); let actorType: ValueOf; if (nextStep.actorAccountID === currentUserAccountID) { actorType = CONST.NEXT_STEP.ACTOR_TYPE.CURRENT_USER; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 33a557b3ebdf..9ded8fd59b33 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1211,6 +1211,10 @@ function createOption({ let reportName; result.participantsList = personalDetailList; + // Resolve display-name translations once per option, then pass the strings to getDisplayNameForParticipant. + const hiddenText = translateFn('common.hidden'); + const youText = translateFn('common.you').toLowerCase(); + if (report) { result.private_isArchived = privateIsArchived; result.keyForList = String(report.reportID); @@ -1271,12 +1275,13 @@ function createOption({ shouldAddCurrentUserPostfix: true, personalDetailsData: personalDetails ?? undefined, formatPhoneNumber: formatPhoneNumberPhoneUtils, - translate: translateFn, + hiddenTranslation: hiddenText, + youTranslation: youText, }) : ''); reportName = showPersonalDetails - ? getDisplayNameForParticipant({accountID: accountIDs.at(0), formatPhoneNumber: formatPhoneNumberPhoneUtils, translate: translateFn}) || + ? getDisplayNameForParticipant({accountID: accountIDs.at(0), formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: hiddenText}) || formatPhoneNumberPhoneUtils(personalDetail?.login ?? '') : computedReportName; } else { @@ -1285,7 +1290,7 @@ function createOption({ accountID: accountIDs.at(0), personalDetailsData: personalDetails ?? undefined, formatPhoneNumber: formatPhoneNumberPhoneUtils, - translate: translateFn, + hiddenTranslation: hiddenText, }) || formatPhoneNumberPhoneUtils(personalDetail?.login ?? ''); result.keyForList = String(accountIDs.at(0)); diff --git a/src/libs/PersonalDetailOptionsListUtils/index.ts b/src/libs/PersonalDetailOptionsListUtils/index.ts index 109bfdee7331..71b3d2bf4cea 100644 --- a/src/libs/PersonalDetailOptionsListUtils/index.ts +++ b/src/libs/PersonalDetailOptionsListUtils/index.ts @@ -78,7 +78,7 @@ function createOption( accountID: personalDetail.accountID, formatPhoneNumber, personalDetailsData: {[personalDetail.accountID]: personalDetail}, - translate, + hiddenTranslation: translate?.('common.hidden'), }) || formatPhoneNumber(personalDetail.login ?? ''); result.icons = [ { diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 71709d6292b3..d723a021d062 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -116,17 +116,19 @@ function temporaryGetDisplayNameOrDefault({ youAfterTranslation, translate, formatPhoneNumber, + hiddenTranslation: hiddenTranslationOverride, }: { passedPersonalDetails?: Partial | null; defaultValue?: string; shouldFallbackToHidden?: boolean; shouldAddCurrentUserPostfix?: boolean; youAfterTranslation?: string; - translate: LocalizedTranslate; + translate?: LocalizedTranslate; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + hiddenTranslation?: string; }): string { - const temporaryHiddenTranslation = translate('common.hidden'); - const temporaryYouTranslation = translate('common.you').toLowerCase(); + const temporaryHiddenTranslation = hiddenTranslationOverride ?? translate?.('common.hidden') ?? hiddenTranslation; + const temporaryYouTranslation = translate?.('common.you').toLowerCase() ?? youTranslation; let displayName = passedPersonalDetails?.displayName ?? ''; const login = passedPersonalDetails?.login ?? ''; diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index da9b38a04e3e..24774816b094 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -236,7 +236,7 @@ const buildReportNameFromParticipantNames = ({ shouldUseShortForm: true, personalDetailsData, formatPhoneNumber: formatPhoneNumberPhoneUtils, - translate, + hiddenTranslation: translate('common.hidden'), }), })) .filter((participant) => participant.name) @@ -247,7 +247,7 @@ const buildReportNameFromParticipantNames = ({ accountID, personalDetailsData, formatPhoneNumber: formatPhoneNumberPhoneUtils, - translate, + hiddenTranslation: translate('common.hidden'), }); } return formattedNames ? `${formattedNames}, ${name}` : name; @@ -293,10 +293,12 @@ function getGroupChatName( const isMultipleParticipantReport = participantAccountIDs.length > 1; if (isMultipleParticipantReport) { + // Resolve the translation once, not per participant. + const hiddenText = translate('common.hidden'); return participantAccountIDs .map( (participantAccountID, index) => - getDisplayNameForParticipant({accountID: participantAccountID, shouldUseShortForm: isMultipleParticipantReport, formatPhoneNumber, translate}) || + getDisplayNameForParticipant({accountID: participantAccountID, shouldUseShortForm: isMultipleParticipantReport, formatPhoneNumber, hiddenTranslation: hiddenText}) || formatPhoneNumber(participants?.[index]?.login ?? ''), ) .sort((first, second) => customCollator.compare(first ?? '', second ?? '')) @@ -305,7 +307,7 @@ function getGroupChatName( .slice(0, CONST.REPORT_NAME_LIMIT) .concat(shouldAddEllipsis ? '...' : ''); } - return translate('groupChat.defaultReportName', getDisplayNameForParticipant({accountID: participantAccountIDs.at(0), formatPhoneNumber, translate})); + return translate('groupChat.defaultReportName', getDisplayNameForParticipant({accountID: participantAccountIDs.at(0), formatPhoneNumber, hiddenTranslation: translate('common.hidden')})); } /** @@ -324,7 +326,9 @@ function getPolicyExpenseChatName({ const personalDetails = ownerAccountID ? personalDetailsList?.[ownerAccountID] : undefined; const login = personalDetails ? personalDetails.login : null; - const reportOwnerDisplayName = getDisplayNameForParticipant({accountID: ownerAccountID, shouldRemoveDomain: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) || login; + const reportOwnerDisplayName = + getDisplayNameForParticipant({accountID: ownerAccountID, shouldRemoveDomain: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: translate('common.hidden')}) || + login; if (reportOwnerDisplayName) { return translate('workspace.common.policyExpenseChatName', reportOwnerDisplayName); @@ -453,7 +457,8 @@ function getMoneyRequestReportName({ const invoiceReceiverPersonalDetail = getInvoiceReceiverPersonalDetail(chatReport, personalDetailsList); payerOrApproverName = getInvoicePayerName(chatReport, translate, invoiceReceiverPersonalDetail, invoiceReceiverPolicy); } else { - payerOrApproverName = getDisplayNameForParticipant({accountID: report?.managerID, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; + payerOrApproverName = + getDisplayNameForParticipant({accountID: report?.managerID, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: translate('common.hidden')}) ?? ''; } const payerPaidAmountMessage = translate('iou.payerPaidAmount', formattedAmount, payerOrApproverName); @@ -466,7 +471,8 @@ function getMoneyRequestReportName({ } if (!isSettled(report?.reportID) && hasNonReimbursableTransactions(linkedTransactions)) { - payerOrApproverName = getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; + payerOrApproverName = + getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: translate('common.hidden')}) ?? ''; return translate('iou.payerSpentAmount', formattedAmount, payerOrApproverName); } @@ -1185,7 +1191,8 @@ function computeReportName({ shouldAddCurrentUserPostfix: true, personalDetailsData: personalDetailsList, formatPhoneNumber: formatPhoneNumberPhoneUtils, - translate, + hiddenTranslation: translate('common.hidden'), + youTranslation: translate('common.you').toLowerCase(), }); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 37bf4a9d4674..ae7ad2958313 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -146,7 +146,7 @@ import Parser from './Parser'; import {getParsedMessageWithShortMentions} from './ParsingUtils'; import {getBankAccountLastFourDigits} from './PaymentUtils'; import Permissions from './Permissions'; -import {getAccountIDsByLogins, getDisplayNameOrDefault, getLoginByAccountID, getPersonalDetailByEmail, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; +import {getAccountIDsByLogins, getLoginByAccountID, getPersonalDetailByEmail, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; import { canSendInvoiceFromWorkspace, getActivePolicies, @@ -3564,7 +3564,8 @@ function getDisplayNameForParticipant({ personalDetailsData = allPersonalDetails, shouldRemoveDomain = false, formatPhoneNumber, - translate, + hiddenTranslation: hiddenTranslationOverride, + youTranslation, }: { accountID?: number; shouldUseShortForm?: boolean; @@ -3573,7 +3574,14 @@ function getDisplayNameForParticipant({ personalDetailsData?: Partial; shouldRemoveDomain?: boolean; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; - translate?: LocalizedTranslate; + /** + * Pre-resolved "Hidden" string, passed instead of a `translate` fn. Callers resolve it once (e.g. `translate('common.hidden')`, + * hoisted out of loops) and pass the value; English-only persisted messages pass the English literal. Defaults to the cached + * module value when omitted. Mirrors `getPolicyName`'s `unavailableTranslation`. + */ + hiddenTranslation?: string; + /** Pre-resolved lowercase "you" string for the `shouldAddCurrentUserPostfix` "(you)" suffix. */ + youTranslation?: string; }): string { if (!accountID) { return ''; @@ -3606,23 +3614,24 @@ function getDisplayNameForParticipant({ // For selfDM, we display the user's displayName followed by '(you)' as a postfix const shouldAddPostfix = shouldAddCurrentUserPostfix && accountID === deprecatedCurrentUserAccountID; - let longName = translate - ? temporaryGetDisplayNameOrDefault({ - passedPersonalDetails: personalDetails, - defaultValue: formattedLogin, - shouldFallbackToHidden, - shouldAddCurrentUserPostfix: shouldAddPostfix, - translate, - formatPhoneNumber, - }) - : getDisplayNameOrDefault(personalDetails, formattedLogin, shouldFallbackToHidden, shouldAddPostfix); + // Resolve the "Hidden" fallback once from the passed string (or the cached module value), never via a per-call `translate`. + const resolvedHiddenTranslation = hiddenTranslationOverride ?? hiddenTranslation; + let longName = temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: personalDetails, + defaultValue: formattedLogin, + shouldFallbackToHidden, + shouldAddCurrentUserPostfix: shouldAddPostfix, + youAfterTranslation: youTranslation, + hiddenTranslation: resolvedHiddenTranslation, + formatPhoneNumber, + }); if (shouldRemoveDomain && longName === formattedLogin) { longName = longName.split('@').at(0) ?? ''; } // If the user's personal details (first name) should be hidden, make sure we return "hidden" instead of the short name - if (shouldFallbackToHidden && longName === (translate ? translate('common.hidden') : hiddenTranslation)) { + if (shouldFallbackToHidden && longName === resolvedHiddenTranslation) { return longName; } @@ -4165,12 +4174,24 @@ function getDisplayNamesWithTooltips( ): DisplayNameWithTooltips { const personalDetailsListArray = Array.isArray(personalDetailsList) ? personalDetailsList : Object.values(personalDetailsList); + // Resolve translations once, not per participant, then pass the strings down. + const hiddenText = translate('common.hidden'); + const youText = translate('common.you').toLowerCase(); + return personalDetailsListArray .map((user) => { const accountID = Number(user?.accountID); const displayName = - getDisplayNameForParticipant({accountID, shouldUseShortForm, shouldFallbackToHidden, shouldAddCurrentUserPostfix, formatPhoneNumber, translate}) || + getDisplayNameForParticipant({ + accountID, + shouldUseShortForm, + shouldFallbackToHidden, + shouldAddCurrentUserPostfix, + formatPhoneNumber, + hiddenTranslation: hiddenText, + youTranslation: youText, + }) || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing user?.login || ''; @@ -4206,7 +4227,7 @@ function getDisplayNamesWithTooltips( * Returns the the display names of the given user accountIDs */ function getUserDetailTooltipText(accountID: number, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocalizedTranslate, fallbackUserDisplayName = ''): string { - const displayNameForParticipant = getDisplayNameForParticipant({accountID, formatPhoneNumber, translate}); + const displayNameForParticipant = getDisplayNameForParticipant({accountID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}); return displayNameForParticipant || fallbackUserDisplayName; } @@ -4250,7 +4271,7 @@ function getReimbursementQueuedActionMessage({ shouldUseShortForm: shouldUseShortDisplayName, personalDetailsData: personalDetails, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }) ?? ''; const originalMessage = getOriginalMessage(reportAction); let messageKey: TranslationPaths; @@ -4278,7 +4299,13 @@ function getReimbursementDeQueuedOrCanceledActionMessage( if (originalMessage?.cancellationReason === CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN || originalMessage?.cancellationReason === CONST.REPORT.CANCEL_PAYMENT_REASONS.USER) { return translate('iou.adminCanceledRequest'); } - const submitterDisplayName = getDisplayNameForParticipant({accountID: reportOwnerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; + const submitterDisplayName = + getDisplayNameForParticipant({ + accountID: reportOwnerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }) ?? ''; return translate('iou.canceledRequest', formattedAmount, submitterDisplayName); } @@ -5841,7 +5868,12 @@ function getReportPreviewMessage( const policyName = getPolicyName({report: parentReport ?? report, policy, unavailableTranslation: translate('workspace.common.unavailable')}); const payerName = isExpenseReport(report) ? policyName - : getDisplayNameForParticipant({accountID: report.managerID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}); + : getDisplayNameForParticipant({ + accountID: report.managerID, + shouldUseShortForm: !isPreviewMessageForParentChatReport, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }); const formattedAmount = convertToDisplayString(totalAmount, report.currency); @@ -5898,7 +5930,12 @@ function getReportPreviewMessage( let actualPayerName = report.managerID === deprecatedCurrentUserAccountID && !isForListPreview ? '' - : getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}); + : getDisplayNameForParticipant({ + accountID: payerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }); actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; @@ -5923,7 +5960,12 @@ function getReportPreviewMessage( if (report.isWaitingOnBankAccount) { const submitterDisplayName = - getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; + getDisplayNameForParticipant({ + accountID: report.ownerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }) ?? ''; return translate('iou.waitingOnBankAccount', submitterDisplayName); } @@ -5952,7 +5994,12 @@ function getReportPreviewMessage( // We only want to show the actor name in the preview if it's not the current user who took the action const requestorName = lastActorID && lastActorID !== deprecatedCurrentUserAccountID - ? getDisplayNameForParticipant({accountID: lastActorID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) + ? getDisplayNameForParticipant({ + accountID: lastActorID, + shouldUseShortForm: !isPreviewMessageForParentChatReport, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }) : ''; return `${requestorName ? `${requestorName}: ` : ''}${translate('iou.expenseAmount', amountToDisplay, comment)}`; } @@ -5961,7 +6008,7 @@ function getReportPreviewMessage( return translate( 'iou.payerSpentAmount', formattedAmount, - getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? '', + getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: translate('common.hidden')}) ?? '', ); } return translate('iou.payerOwesAmount', formattedAmount, payerName ?? '', comment); @@ -6316,7 +6363,12 @@ function getPayeeName(report: OnyxEntry, translate: LocalizedTranslate): if (participantsWithoutCurrentUser.length === 0) { return undefined; } - return getDisplayNameForParticipant({accountID: participantsWithoutCurrentUser.at(0), shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}); + return getDisplayNameForParticipant({ + accountID: participantsWithoutCurrentUser.at(0), + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }); } // TODO: currentUserEmail will be required eventually so this becomes a pure function. Subscribe the data via useOnyx and pass it from the component. Refactor issue: https://github.com/Expensify/App/issues/66412 @@ -6436,7 +6488,12 @@ function getParentNavigationSubtitle( const login = personalDetails ? personalDetails.login : null; const reportOwnerDisplayName = - getDisplayNameForParticipant({accountID: ownerAccountID, shouldRemoveDomain: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) || login; + getDisplayNameForParticipant({ + accountID: ownerAccountID, + shouldRemoveDomain: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }) || login; if (isExpenseReport(report)) { return { @@ -8800,7 +8857,7 @@ function buildOptimisticChangedTaskAssigneeReportAction( message: [ { type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - text: `assigned to ${getDisplayNameForParticipant({accountID: assigneeAccountID, formatPhoneNumber})}`, + text: `assigned to ${getDisplayNameForParticipant({accountID: assigneeAccountID, formatPhoneNumber, hiddenTranslation: CONST.HIDDEN_DISPLAY_NAME})}`, html: `assigned to `, }, ], @@ -8944,7 +9001,7 @@ function buildOptimisticChangeApproverReportAction( message: [ { type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - text: `changed the approver to ${getDisplayNameForParticipant({accountID: managerID, formatPhoneNumber})}`, + text: `changed the approver to ${getDisplayNameForParticipant({accountID: managerID, formatPhoneNumber, hiddenTranslation: CONST.HIDDEN_DISPLAY_NAME})}`, html: `changed the approver to `, }, ], @@ -10688,8 +10745,10 @@ function getWhisperDisplayNames(translate: LocalizedTranslate, formatPhoneNumber return translate('common.youAfterPreposition'); } + // Resolve the translation once, not per participant. + const hiddenText = translate('common.hidden'); return participantAccountIDs - ?.map((accountID) => getDisplayNameForParticipant({accountID, shouldUseShortForm: !isWhisperOnlyVisibleToCurrentUser, formatPhoneNumber, translate})) + ?.map((accountID) => getDisplayNameForParticipant({accountID, shouldUseShortForm: !isWhisperOnlyVisibleToCurrentUser, formatPhoneNumber, hiddenTranslation: hiddenText})) .join(', '); } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 3993e4de416b..279bf292d906 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -1487,7 +1487,7 @@ function getWelcomeMessage(params: WelcomeMessageParams): WelcomeMessage { welcomeMessage.messageHtml = translate( 'reportActionsView.beginningOfChatHistoryPolicyExpenseChat', getPolicyName({report, policy, unavailableTranslation: translate('workspace.common.unavailable')}), - getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber, translate}), + getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}), ); welcomeMessage.messageText = Parser.htmlToText(welcomeMessage.messageHtml); } @@ -1571,7 +1571,7 @@ function getRoomWelcomeMessage({ } else if (isInvoiceRoom(report)) { const payer = report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL - ? getDisplayNameForParticipant({accountID: report?.invoiceReceiver?.accountID, formatPhoneNumber, translate}) + ? getDisplayNameForParticipant({accountID: report?.invoiceReceiver?.accountID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) : invoiceReceiverPolicy?.name; const receiver = getPolicyName({report, unavailableTranslation: translate('workspace.common.unavailable')}); welcomeMessage.messageHtml = translate('reportActionsView.beginningOfChatHistoryInvoiceRoom', payer ?? '', receiver); diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts index 28636f7be71b..b5b6f75f55c1 100644 --- a/src/libs/SuggestionUtils.ts +++ b/src/libs/SuggestionUtils.ts @@ -11,8 +11,8 @@ function trimLeadingSpace(str: string): string { return str.startsWith(' ') ? str.slice(1) : str; } -function getDisplayName(details: PersonalDetails, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocaleContextProps['translate']) { - const displayNameFromAccountID = getDisplayNameForParticipant({accountID: details.accountID, formatPhoneNumber, translate}); +function getDisplayName(details: PersonalDetails, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], hiddenTranslation: string) { + const displayNameFromAccountID = getDisplayNameForParticipant({accountID: details.accountID, formatPhoneNumber, hiddenTranslation}); if (!displayNameFromAccountID) { return details.login?.length ? details.login : ''; } @@ -28,12 +28,14 @@ function getSortedPersonalDetails( formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocaleContextProps['translate'], ) { + // Resolve the translation once, not per comparison. + const hiddenText = translate('common.hidden'); return personalDetails.sort((first, second) => { if (first.weight !== second.weight) { return first.weight - second.weight; } - const displayNameLoginOrder = localeCompare(getDisplayName(first, formatPhoneNumber, translate), getDisplayName(second, formatPhoneNumber, translate)); + const displayNameLoginOrder = localeCompare(getDisplayName(first, formatPhoneNumber, hiddenText), getDisplayName(second, formatPhoneNumber, hiddenText)); if (displayNameLoginOrder !== 0) { return displayNameLoginOrder; } diff --git a/src/pages/ReportAddApproverPage.tsx b/src/pages/ReportAddApproverPage.tsx index c9a5c947c3b0..75d03b9f1863 100644 --- a/src/pages/ReportAddApproverPage.tsx +++ b/src/pages/ReportAddApproverPage.tsx @@ -62,6 +62,8 @@ function ReportAddApproverPage({report, isLoadingReportData, policy}: ReportAddA } const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, true, false); + // Resolve the translation once, not per member. + const hiddenText = translate('common.hidden'); return Object.values(employeeList) .map((employee): SelectionListApprover | null => { const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; @@ -79,7 +81,7 @@ function ReportAddApproverPage({report, isLoadingReportData, policy}: ReportAddA } const {avatar} = personalDetails?.[accountID] ?? {}; - const displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails, formatPhoneNumber, translate}); + const displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails, formatPhoneNumber, hiddenTranslation: hiddenText}); return { text: displayName, alternateText: email, diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index 71f678cea82a..3464b94ff96a 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -58,6 +58,8 @@ function SearchAddApproverPage() { const intersectedEmployees = firstWorkspaceEmployees ? lodashPick(firstWorkspaceEmployees, lodashIntersection(...employeeLists.map(Object.keys))) : {}; const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(intersectedEmployees, true, false); // We get the intersection here as we only want to show members who belong to all workspaces when adding an additional approver + // Resolve the translation once, not per member. + const hiddenText = translate('common.hidden'); return Object.values(intersectedEmployees) .map((employee): SelectionListApprover | null => { const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; @@ -106,7 +108,7 @@ function SearchAddApproverPage() { } const {avatar} = personalDetails?.[accountID] ?? {}; - const displayName = getDisplayNameForParticipant({accountID, formatPhoneNumber, personalDetailsData: personalDetails, translate}); + const displayName = getDisplayNameForParticipant({accountID, formatPhoneNumber, personalDetailsData: personalDetails, hiddenTranslation: hiddenText}); return { text: displayName, alternateText: email, diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index 18e40346e74f..2eb2045f9940 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -120,9 +120,10 @@ function ShareCodePage({report, policy, backTo}: ShareCodePageProps) { return getPolicyName({report, unavailableTranslation: translate('workspace.common.unavailable')}); } if (isMoneyRequestReport(report)) { - // generate subtitle from participants + // generate subtitle from participants; resolve the translation once, not per participant + const hiddenText = translate('common.hidden'); return getParticipantsAccountIDsForDisplay(report, true) - .map((accountID) => getDisplayNameForParticipant({accountID, formatPhoneNumber, translate})) + .map((accountID) => getDisplayNameForParticipant({accountID, formatPhoneNumber, hiddenTranslation: hiddenText})) .join(' & '); } diff --git a/src/pages/inbox/report/ReportTypingIndicator.tsx b/src/pages/inbox/report/ReportTypingIndicator.tsx index 9d9ad4d59c8e..2e9b440d4c45 100755 --- a/src/pages/inbox/report/ReportTypingIndicator.tsx +++ b/src/pages/inbox/report/ReportTypingIndicator.tsx @@ -35,7 +35,7 @@ function ReportTypingIndicator({reportID}: ReportTypingIndicatorProps) { // If the user is typing on OldDot, firstUserTyping will be a string (the user's displayName) const firstUserTypingDisplayName = isUserTypingADisplayName ? firstUserTyping - : getDisplayNameForParticipant({accountID: Number(firstUserTyping), shouldFallbackToHidden: false, formatPhoneNumber, translate}); + : getDisplayNameForParticipant({accountID: Number(firstUserTyping), shouldFallbackToHidden: false, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}); if (usersTyping.length === 1) { return ( diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx index a9619dce4eb4..20f12260c240 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx @@ -97,7 +97,7 @@ function QuickActionMenuItem({reportID}: QuickActionMenuItemProps) { if (!isEmptyObject(quickActionReport)) { if (quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && quickActionAvatars.length > 0) { const accountID = quickActionAvatars.at(0)?.id ?? CONST.DEFAULT_NUMBER_ID; - const name = getDisplayNameForParticipant({accountID: Number(accountID), shouldUseShortForm: true, formatPhoneNumber, translate}) ?? ''; + const name = getDisplayNameForParticipant({accountID: Number(accountID), shouldUseShortForm: true, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) ?? ''; quickActionTitle = translate('quickAction.paySomeone', name); } else { const titleKey = getQuickActionTitle(quickAction?.action ?? ('' as QuickActionName)); diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 4349da8316b0..957f9bb1dd9a 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -162,14 +162,15 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const canSelectMultiple = canWriteMembers && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); const confirmModalPrompt = useMemo(() => { + const hiddenText = translate('common.hidden'); const approverEmail = selectedEmployees.find((selectedEmployee) => isPolicyApprover(policy, selectedEmployee)); if (approverEmail) { const approverAccountID = policyMemberEmailsToAccountIDs[approverEmail]; return translate( 'workspace.people.removeMembersWarningPrompt', - getDisplayNameForParticipant({accountID: approverAccountID, formatPhoneNumber, translate}), - getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, translate}), + getDisplayNameForParticipant({accountID: approverAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), + getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), ); } @@ -179,8 +180,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers if (userExporter) { const exporterAccountID = policyMemberEmailsToAccountIDs[userExporter]; return translate('workspace.people.removeMemberPromptExporter', { - memberName: getDisplayNameForParticipant({accountID: exporterAccountID, formatPhoneNumber, translate}), - workspaceOwner: getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, translate}), + memberName: getDisplayNameForParticipant({accountID: exporterAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), + workspaceOwner: getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), }); } diff --git a/tests/unit/QuickActionMenuItemTest.tsx b/tests/unit/QuickActionMenuItemTest.tsx index 968b41e34d72..b59787dc73e7 100644 --- a/tests/unit/QuickActionMenuItemTest.tsx +++ b/tests/unit/QuickActionMenuItemTest.tsx @@ -64,7 +64,7 @@ describe('QuickActionMenuItem', () => { await waitForBatchedUpdates(); // The pay-someone quick action resolves the payee name via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: AVATAR_ACCOUNT_ID, shouldUseShortForm: true, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: AVATAR_ACCOUNT_ID, shouldUseShortForm: true, hiddenTranslation: 'common.hidden'})); expect(mockTranslate).toHaveBeenCalledWith('quickAction.paySomeone', 'SPY_NAME'); }); }); diff --git a/tests/unit/ReportAddApproverPageTranslateTest.tsx b/tests/unit/ReportAddApproverPageTranslateTest.tsx index c6f2c5af13cd..1750d98836ad 100644 --- a/tests/unit/ReportAddApproverPageTranslateTest.tsx +++ b/tests/unit/ReportAddApproverPageTranslateTest.tsx @@ -92,6 +92,6 @@ describe('ReportAddApproverPage', () => { await waitForBatchedUpdates(); // Each candidate approver's name resolves via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: APPROVER_ACCOUNT_ID, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: APPROVER_ACCOUNT_ID, hiddenTranslation: 'common.hidden'})); }); }); diff --git a/tests/unit/ReportTypingIndicatorTest.tsx b/tests/unit/ReportTypingIndicatorTest.tsx index adf7b3cf6afb..e09ec3132d70 100644 --- a/tests/unit/ReportTypingIndicatorTest.tsx +++ b/tests/unit/ReportTypingIndicatorTest.tsx @@ -57,7 +57,9 @@ describe('ReportTypingIndicator', () => { await waitForBatchedUpdates(); // The typing user's name resolves via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: TYPING_ACCOUNT_ID, shouldFallbackToHidden: false, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith( + expect.objectContaining({accountID: TYPING_ACCOUNT_ID, shouldFallbackToHidden: false, hiddenTranslation: 'common.hidden'}), + ); expect(screen.getByText('SPY_NAME')).toBeOnTheScreen(); }); }); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 931b0b781a82..d73a9498d407 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -15284,7 +15284,7 @@ describe('ReportUtils', () => { formatPhoneNumber, accountID: hiddenAccountID, personalDetailsData: personalDetailsWithHidden, - translate: translateLocal, + hiddenTranslation: translateLocal('common.hidden'), }); expect(result).toBe(translateLocal('common.hidden')); @@ -15311,23 +15311,21 @@ describe('ReportUtils', () => { accountID: hiddenAccountID, shouldUseShortForm: true, personalDetailsData: personalDetailsWithHidden, - translate: translateLocal, + hiddenTranslation: translateLocal('common.hidden'), }); expect(result).toBe(translateLocal('common.hidden')); expect(result).not.toBe('ShortName'); }); - it('resolves the hidden participant fallback through the provided translate function', () => { + it('resolves the hidden participant fallback through the provided hiddenTranslation string', () => { const hiddenAccountID = 909090; - // A known participant with no displayName/login resolves to the hidden label, which must come from the provided translate function. - const translateWithHiddenMarker: LocalizedTranslate = (path, ...parameters) => (path === 'common.hidden' ? 'HiddenMarker' : translateLocal(path, ...parameters)); - + // A known participant with no displayName/login resolves to the hidden label, which must come from the provided hiddenTranslation string. const displayName = getDisplayNameForParticipant({ accountID: hiddenAccountID, formatPhoneNumber, personalDetailsData: {[hiddenAccountID]: {accountID: hiddenAccountID, login: '', displayName: ''}}, - translate: translateWithHiddenMarker, + hiddenTranslation: 'HiddenMarker', }); expect(displayName).toBe('HiddenMarker'); diff --git a/tests/unit/SearchAddApproverPageTranslateTest.tsx b/tests/unit/SearchAddApproverPageTranslateTest.tsx index 134ecbfeabdf..8426219ec673 100644 --- a/tests/unit/SearchAddApproverPageTranslateTest.tsx +++ b/tests/unit/SearchAddApproverPageTranslateTest.tsx @@ -90,6 +90,6 @@ describe('SearchAddApproverPage', () => { await waitForBatchedUpdates(); // Each candidate approver's name resolves via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: APPROVER_ACCOUNT_ID, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: APPROVER_ACCOUNT_ID, hiddenTranslation: 'common.hidden'})); }); }); diff --git a/tests/unit/ShareCodePageTranslateTest.tsx b/tests/unit/ShareCodePageTranslateTest.tsx index 6284dc048ec3..87b2cc6966b4 100644 --- a/tests/unit/ShareCodePageTranslateTest.tsx +++ b/tests/unit/ShareCodePageTranslateTest.tsx @@ -89,7 +89,7 @@ describe('ShareCodePage', () => { await waitForBatchedUpdates(); // Each participant's name resolves via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({hiddenTranslation: 'common.hidden'})); }); describe('profile QR code avatar logo', () => { diff --git a/tests/unit/SuggestionUtilsTranslateTest.ts b/tests/unit/SuggestionUtilsTranslateTest.ts index 1407b31eef81..f18c6227c04a 100644 --- a/tests/unit/SuggestionUtilsTranslateTest.ts +++ b/tests/unit/SuggestionUtilsTranslateTest.ts @@ -27,14 +27,14 @@ describe('SuggestionUtils - getSortedPersonalDetails translate threading', () => jest.clearAllMocks(); }); - it('passes the provided translate function to getDisplayNameForParticipant', () => { + it('passes the resolved hidden string to getDisplayNameForParticipant', () => { const first = {login: 'first@test.com', weight: 2, accountID: 801} as PersonalDetails & {weight: number}; const second = {login: 'second@test.com', weight: 2, accountID: 802} as PersonalDetails & {weight: number}; getSortedPersonalDetails([second, first], localeCompare, formatPhoneNumber, translateLocal); - // The sort comparator resolves each display name via getDisplayNameForParticipant, which must receive the provided translate. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({translate: translateLocal})); + // The sort comparator resolves each display name via getDisplayNameForParticipant, which must receive the resolved "Hidden" string. + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({hiddenTranslation: translateLocal('common.hidden')})); }); it('sorts by the display name resolved through the provided translate function', () => { diff --git a/tests/unit/TaskViewTranslateTest.tsx b/tests/unit/TaskViewTranslateTest.tsx index 5656e131128a..404c621c5287 100644 --- a/tests/unit/TaskViewTranslateTest.tsx +++ b/tests/unit/TaskViewTranslateTest.tsx @@ -70,6 +70,6 @@ describe('TaskView', () => { await waitForBatchedUpdates(); // The assignee menu item resolves its title via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: TASK_MANAGER_ACCOUNT_ID, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: TASK_MANAGER_ACCOUNT_ID, hiddenTranslation: 'common.hidden'})); }); }); diff --git a/tests/unit/UserSelectionListItemTest.tsx b/tests/unit/UserSelectionListItemTest.tsx index 7446855b8c76..2eb2b69f0686 100644 --- a/tests/unit/UserSelectionListItemTest.tsx +++ b/tests/unit/UserSelectionListItemTest.tsx @@ -50,6 +50,6 @@ describe('UserSelectionListItem', () => { ); // The row's display name resolves via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: ITEM_ACCOUNT_ID, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: ITEM_ACCOUNT_ID, hiddenTranslation: 'common.hidden'})); }); }); diff --git a/tests/unit/usePreviewMessageAnimationTest.ts b/tests/unit/usePreviewMessageAnimationTest.ts index 289371792b88..e047b513eb70 100644 --- a/tests/unit/usePreviewMessageAnimationTest.ts +++ b/tests/unit/usePreviewMessageAnimationTest.ts @@ -59,7 +59,7 @@ describe('usePreviewMessageAnimation', () => { const {result} = renderHook(() => usePreviewMessageAnimation(baseParams)); // The hook resolves the payer/approver name via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: MANAGER_ID, shouldUseShortForm: true, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: MANAGER_ID, shouldUseShortForm: true, hiddenTranslation: 'common.hidden'})); expect(result.current.previewMessageStyle).toBeDefined(); }); @@ -69,6 +69,6 @@ describe('usePreviewMessageAnimation', () => { renderHook(() => usePreviewMessageAnimation({...baseParams, hasNonReimbursableTransactions: true, chatReport})); // The payerSpent branch re-resolves the name from the chat owner, and it must also receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: CHAT_OWNER_ID, shouldUseShortForm: true, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: CHAT_OWNER_ID, shouldUseShortForm: true, hiddenTranslation: 'common.hidden'})); }); }); diff --git a/tests/unit/useReportActionAvatarsTranslateTest.tsx b/tests/unit/useReportActionAvatarsTranslateTest.tsx index 7998f89abb8d..f67480f07499 100644 --- a/tests/unit/useReportActionAvatarsTranslateTest.tsx +++ b/tests/unit/useReportActionAvatarsTranslateTest.tsx @@ -52,6 +52,6 @@ describe('useReportActionAvatars translate wiring', () => { renderHook(() => useReportActionAvatars({report, action}), {wrapper}); // The hook resolves the default display name via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: ACTOR_ACCOUNT_ID, translate: mockTranslate})); + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: ACTOR_ACCOUNT_ID, hiddenTranslation: 'common.hidden'})); }); }); From aea4cf7ba859b9093912df87afc13b9c6f0920ce Mon Sep 17 00:00:00 2001 From: daledah Date: Mon, 17 Aug 2026 14:35:17 +0700 Subject: [PATCH 016/485] fix knip and rename variable --- src/libs/PersonalDetailsUtils.ts | 72 ++------------------------------ src/libs/ReportUtils.ts | 2 +- 2 files changed, 5 insertions(+), 69 deletions(-) diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index d723a021d062..38e241d7f02c 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -15,7 +15,6 @@ import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import {getCountryCode} from './CountryUtils'; -import {translateLocal} from './Localize'; import {areEmailsFromSamePrivateDomain} from './LoginUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from './PhoneNumber'; import {getDefaultAvatarURL} from './UserAvatarUtils'; @@ -44,91 +43,29 @@ Onyx.connect({ }, }); -let hiddenTranslation = ''; -let youTranslation = ''; - -Onyx.connect({ - key: ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, - callback: (value) => { - if (value ?? true) { - return; - } - hiddenTranslation = translateLocal('common.hidden'); - youTranslation = translateLocal('common.you').toLowerCase(); - }, -}); - const regexMergedAccount = new RegExp(CONST.REGEX.MERGED_ACCOUNT_PREFIX); -function getDisplayNameOrDefault( - passedPersonalDetails?: Partial | null, - defaultValue = '', - shouldFallbackToHidden = true, - shouldAddCurrentUserPostfix = false, - youAfterTranslation = youTranslation, -): string { - let displayName = passedPersonalDetails?.displayName ?? ''; - - let login = passedPersonalDetails?.login ?? ''; - - // If the displayName starts with the merged account prefix, remove it. - if (regexMergedAccount.test(displayName)) { - // Remove the merged account prefix from the displayName. - displayName = displayName.replaceAll(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); - } - - // If the displayName is not set by the user, the backend sets the displayName same as the login so - // we need to remove the sms domain from the displayName if it is an sms login. - if (Str.isSMSLogin(login)) { - if (displayName === login) { - displayName = Str.removeSMSDomain(displayName); - } - login = Str.removeSMSDomain(login); - } - - if (shouldAddCurrentUserPostfix && !!displayName) { - displayName = `${displayName} (${youAfterTranslation})`; - } - - if (passedPersonalDetails?.accountID === CONST.ACCOUNT_ID.CONCIERGE) { - displayName = CONST.CONCIERGE_DISPLAY_NAME; - } - - if (displayName) { - return displayName; - } - - if (defaultValue) { - return defaultValue; - } - - if (login) { - return login; - } - return shouldFallbackToHidden ? hiddenTranslation : ''; -} - function temporaryGetDisplayNameOrDefault({ passedPersonalDetails, defaultValue = '', shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false, youAfterTranslation, + hiddenAfterTranslation, translate, formatPhoneNumber, - hiddenTranslation: hiddenTranslationOverride, }: { passedPersonalDetails?: Partial | null; defaultValue?: string; shouldFallbackToHidden?: boolean; shouldAddCurrentUserPostfix?: boolean; youAfterTranslation?: string; + hiddenAfterTranslation?: string; translate?: LocalizedTranslate; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; - hiddenTranslation?: string; }): string { - const temporaryHiddenTranslation = hiddenTranslationOverride ?? translate?.('common.hidden') ?? hiddenTranslation; - const temporaryYouTranslation = translate?.('common.you').toLowerCase() ?? youTranslation; + const temporaryHiddenTranslation = hiddenAfterTranslation ?? translate?.('common.hidden') ?? ''; + const temporaryYouTranslation = translate?.('common.you').toLowerCase(); let displayName = passedPersonalDetails?.displayName ?? ''; const login = passedPersonalDetails?.login ?? ''; @@ -579,7 +516,6 @@ function areTravelPersonalDetailsMissing(privatePersonalDetails: OnyxEntry Date: Mon, 17 Aug 2026 14:55:36 +0700 Subject: [PATCH 017/485] fix jest test --- tests/unit/useReportActionAvatarsTranslateTest.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/useReportActionAvatarsTranslateTest.tsx b/tests/unit/useReportActionAvatarsTranslateTest.tsx index f67480f07499..b13c1ed4f3b2 100644 --- a/tests/unit/useReportActionAvatarsTranslateTest.tsx +++ b/tests/unit/useReportActionAvatarsTranslateTest.tsx @@ -51,7 +51,7 @@ describe('useReportActionAvatars translate wiring', () => { renderHook(() => useReportActionAvatars({report, action}), {wrapper}); - // The hook resolves the default display name via getDisplayNameForParticipant, which must receive the translate from useLocalize. - expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: ACTOR_ACCOUNT_ID, hiddenTranslation: 'common.hidden'})); + // The hook resolves the default display name via getDisplayNameForParticipant, which must receive the resolved "Hidden" string from useLocalize. + expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({hiddenTranslation: 'common.hidden'})); }); }); From e93d9f4ebe32c2c0503680d88a03b53059ef2fd7 Mon Sep 17 00:00:00 2001 From: daledah Date: Wed, 19 Aug 2026 14:32:55 +0700 Subject: [PATCH 018/485] fix comments --- src/libs/ReportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 07e5684cce42..56aa91af8d8d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3581,7 +3581,7 @@ function getDisplayNameForParticipant({ formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; /** * Pre-resolved "Hidden" string, passed instead of a `translate` fn. Callers resolve it once (e.g. `translate('common.hidden')`, - * hoisted out of loops) and pass the value; English-only persisted messages pass the English literal. Defaults to the cached + * hoisted out of loops) and pass the value. English-only persisted messages pass the English literal. Defaults to the cached * module value when omitted. Mirrors `getPolicyName`'s `unavailableTranslation`. */ hiddenTranslation?: string; From cac38d95503628ede43db61f6087d22345116c1f Mon Sep 17 00:00:00 2001 From: "Gandalf (via MelvinBot)" Date: Fri, 21 Aug 2026 01:15:56 +0000 Subject: [PATCH 019/485] Docs: duplicating an unreported expense keeps the copy unreported Co-authored-by: Gandalf --- .../reports-and-expenses/How-to-Duplicate-an-Expense.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md index ffdb7b5a68bc..9e4920aacdfa 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md @@ -7,7 +7,7 @@ internalScope: Audience is all members. Covers how to duplicate a single expense # How to duplicate an expense -You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses on your primary workspace with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. +You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. A reported expense is copied onto your primary workspace, while an Unreported expense is copied as an Unreported expense. This is useful when you need to create similar expenses quickly without re-entering all the details manually. You can duplicate a single expense or select multiple expenses and duplicate them all at once. @@ -68,7 +68,9 @@ Purpose: Helps the user identify the bulk duplicate option in the dropdown. ## What happens after you duplicate an expense -The duplicate expense is created on your primary workspace with the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included. +The duplicate expense keeps the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included. + +The duplicate matches the reported state of the original. If the original is a reported expense, the copy is created on your primary workspace. If the original is an Unreported expense, the copy is also created as an Unreported expense instead of being added to a report. If the original expense's coding (such as categories or tags) is not available on your primary workspace, the duplicate expense will still be created. You will be prompted to correct the coding on the expense. From d74cca1598a620c1ef88793285212f39bdda057d Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:50:45 +0530 Subject: [PATCH 020/485] refactor(report-utils): return slice from findLastAccessedReport Callers only consume reportID, policyID, and chatType, so the function now returns a minimal LastAccessedReport slice instead of the full Report. Extracts a pure findLastAccessedReportSelector that takes the collections as arguments; the module-scoped findLastAccessedReport wrapper keeps feeding it the intentional module subscriptions (non-UI, no view-based re-renders). navigateAfterOnboarding drops isConciergeChatReport/isSelfDM calls and compares slice fields directly. Refs https://github.com/Expensify/App/pull/98719#issuecomment-5367196477 --- src/libs/ReportUtils.ts | 89 ++++++++++++++++++----- src/libs/navigateAfterOnboarding.ts | 9 ++- tests/unit/navigateAfterOnboardingTest.ts | 6 +- 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 37bf4a9d4674..19df49b69c6d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2450,26 +2450,52 @@ function getMostRecentlyVisitedReport(reports: Array>, lastVis return lodashMaxBy(filteredReports, (a) => [(a?.reportID && lastVisitTimes?.[a.reportID]) ?? '', a?.lastReadTime ?? '']); } +type FindLastAccessedReportOptions = { + /** Whether to exclude domain rooms that are on the defaultRooms beta. */ + ignoreDomainRooms: boolean; + /** Whether to prefer the policy admins room when one exists. */ + openOnAdminRoom?: boolean; + /** Report ID to exclude from the result (e.g. the report being left). */ + excludeReportID?: string; +}; + /** - * This function is used to find the last accessed report and we don't need to subscribe the data in the UI. - * So please use `Onyx.connectWithoutView()` to get the necessary data when we remove the `Onyx.connect()` + * Minimal slice of a Report that `findLastAccessedReport` callers consume. + * Returning only the needed fields keeps the contract explicit and avoids handing + * the full Report object to navigation fallbacks. */ -function findLastAccessedReport( - ignoreDomainRooms: boolean, - openOnAdminRoom = false, - excludeReportID?: string, - reportNameValuePairs?: OnyxCollection, -): OnyxEntry { - const reportNameValuePairsCollection = reportNameValuePairs ?? allReportNameValuePair; - let reportsValues = Object.values(deprecatedAllReports ?? {}); +type LastAccessedReport = { + reportID: string; + policyID?: string; + chatType?: ValueOf; +}; + +function toLastAccessedReport(report: OnyxEntry): LastAccessedReport | undefined { + if (!report?.reportID) { + return undefined; + } + return {reportID: report.reportID, policyID: report.policyID, chatType: report.chatType}; +} + +/** + * Pure selector that computes the last accessed report from the supplied collections. + * It does not subscribe to Onyx itself, so callers decide how data reaches it: + * the module-scoped `findLastAccessedReport` wrapper feeds it the module subscriptions, + * and tests can pass curated collections directly. + */ +function findLastAccessedReportSelector( + reports: OnyxCollection, + reportNameValuePairs: OnyxCollection, + lastVisitTimes: Record, + options: FindLastAccessedReportOptions, +): LastAccessedReport | undefined { + const {ignoreDomainRooms, openOnAdminRoom = false, excludeReportID} = options; + let reportsValues = Object.values(reports ?? {}); if (openOnAdminRoom) { - const adminReport = reportsValues.find((report) => { - const chatType = getChatType(report); - return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; - }); + const adminReport = reportsValues.find((report) => getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS); if (adminReport) { - return adminReport; + return toLastAccessedReport(adminReport); } } @@ -2497,22 +2523,42 @@ function findLastAccessedReport( reportsValues = reportsValues.filter((report) => { const reportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`; - const isArchived = isArchivedReport(reportNameValuePairsCollection?.[reportNameValuePairsKey]); + const isArchived = isArchivedReport(reportNameValuePairs?.[reportNameValuePairsKey]); return !isSystemChat(report) && !isArchived; }) ?? []; // At least two reports remain: self DM and Concierge chat. // Return the most recently visited report. Get the last read report from the last-visit-times map. // If we have no visit data we'll return most recent report owned by user. - if (isEmptyObject(allReportLastVisitTimes)) { + if (isEmptyObject(lastVisitTimes)) { const visibleReports = reportsValues.filter((report) => !!report?.isPinned || !isHiddenForCurrentUser(report) || (isPublicRoom(report) && isAnonymousUserSession())); const ownedReports = visibleReports.filter((report) => report?.ownerAccountID === deprecatedCurrentUserAccountID); if (ownedReports.length > 0) { - return lodashMaxBy(ownedReports, (a) => a?.lastReadTime ?? ''); + return toLastAccessedReport(lodashMaxBy(ownedReports, (a) => a?.lastReadTime ?? '')); } - return lodashMaxBy(reportsValues, (a) => a?.lastReadTime ?? ''); + return toLastAccessedReport(lodashMaxBy(reportsValues, (a) => a?.lastReadTime ?? '')); } - return getMostRecentlyVisitedReport(reportsValues, allReportLastVisitTimes); + return toLastAccessedReport(getMostRecentlyVisitedReport(reportsValues, lastVisitTimes)); +} + +/** + * Finds the last accessed report for navigation fallbacks without triggering a view-based + * Onyx subscription. It reads the report and last-visit-times collections from the + * module-scoped subscriptions (intentional — see the `connectWithoutView` calls above) + * and only takes the name-value pairs as an explicit argument so callers can pass a + * curated collection. Returns a minimal slice instead of the full Report. + */ +function findLastAccessedReport( + ignoreDomainRooms: boolean, + openOnAdminRoom = false, + excludeReportID?: string, + reportNameValuePairs?: OnyxCollection, +): LastAccessedReport | undefined { + return findLastAccessedReportSelector(deprecatedAllReports, reportNameValuePairs ?? allReportNameValuePair, allReportLastVisitTimes, { + ignoreDomainRooms, + openOnAdminRoom, + excludeReportID, + }); } /** @@ -14161,6 +14207,7 @@ export { doesReportBelongToWorkspace, shouldEnableNegative, findLastAccessedReport, + findLastAccessedReportSelector, findSelfDMReportID, formatReportLastMessageText, generateReportID, @@ -14492,4 +14539,6 @@ export type { SelfDMParameters, OptimisticReportAction, ActionErrorsByTransaction, + FindLastAccessedReportOptions, + LastAccessedReport, }; diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 82aa05acc0e3..91efe6816ecb 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -16,7 +16,7 @@ import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostS import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; -import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; +import {findLastAccessedReport} from './ReportUtils'; import {buildCannedSearchQuery} from './SearchQueryUtils'; let onboardingRHPVariant: OnyxEntry; @@ -61,7 +61,12 @@ function getReportIDAfterOnboarding( // When the user goes through the onboarding flow, a workspace can be created if the user selects specific options. The user should be taken to the #admins room for that workspace because it is the most natural place for them to start their experience in the app. // The user should never go to the self DM or the Concierge chat if a workspace was created during the onboarding flow. - if (lastAccessedReportID && lastAccessedReport.policyID !== onboardingPolicyID && !isConciergeChatReport(lastAccessedReport, conciergeReportID) && !isSelfDM(lastAccessedReport)) { + if ( + lastAccessedReportID && + lastAccessedReport.policyID !== onboardingPolicyID && + lastAccessedReport.reportID !== conciergeReportID && + lastAccessedReport.chatType !== CONST.REPORT.CHAT_TYPE.SELF_DM + ) { return lastAccessedReportID; } diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index fa80e72e0767..898591e8ac2a 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,5 +1,6 @@ import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; +import type {LastAccessedReport} from '@libs/ReportUtils'; import type * as ReportUtils from '@libs/ReportUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -7,9 +8,6 @@ import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Report} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -19,7 +17,7 @@ const ONBOARDING_ADMINS_CHAT_REPORT_ID = '1'; const ONBOARDING_POLICY_ID = '2'; const REPORT_ID = '3'; const USER_ID = '4'; -const mockFindLastAccessedReport = jest.fn, Parameters>(); +const mockFindLastAccessedReport = jest.fn>(); const mockShouldOpenOnAdminRoom = jest.fn(() => false); const mockIsReportTopmostSplitNavigator = jest.fn(() => false); From a74cc2f5f53b14a0a76bc842a585c91280f6e040 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:00:58 +0530 Subject: [PATCH 021/485] refactor(report-utils): trim findLastAccessedReport comments Drop verbose rationale and fix the stale "only takes the name-value pairs" line, since the wrapper now also accepts a `reports` argument. --- src/libs/ReportUtils.ts | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 37faf5b66cfc..532e47f551dd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2467,11 +2467,7 @@ type FindLastAccessedReportOptions = { excludeReportID?: string; }; -/** - * Minimal slice of a Report that `findLastAccessedReport` callers consume. - * Returning only the needed fields keeps the contract explicit and avoids handing - * the full Report object to navigation fallbacks. - */ +/** Fields of a Report that `findLastAccessedReport` callers consume. */ type LastAccessedReport = { reportID: string; policyID?: string; @@ -2485,12 +2481,7 @@ function toLastAccessedReport(report: OnyxEntry): LastAccessedReport | u return {reportID: report.reportID, policyID: report.policyID, chatType: report.chatType}; } -/** - * Pure selector that computes the last accessed report from the supplied collections. - * It does not subscribe to Onyx itself, so callers decide how data reaches it: - * the module-scoped `findLastAccessedReport` wrapper feeds it the module subscriptions, - * and tests can pass curated collections directly. - */ +/** Pure selector; callers pass the collections, so it has no Onyx subscription of its own. */ function findLastAccessedReportSelector( reports: OnyxCollection, reportNameValuePairs: OnyxCollection, @@ -2550,15 +2541,10 @@ function findLastAccessedReportSelector( } /** - * Finds the last accessed report for navigation fallbacks without triggering a view-based - * Onyx subscription. It reads the report and last-visit-times collections from the - * module-scoped subscriptions (intentional — see the `connectWithoutView` calls above) - * and only takes the name-value pairs as an explicit argument so callers can pass a - * curated collection. Returns a minimal slice instead of the full Report. - * - * Callers that need to react to the report data arriving (rather than reading whatever the module-scoped - * copy happens to hold at call time) can pass their own subscribed collections through `reportNameValuePairs` - * and `reports`. Both fall back to the module-scoped copies when omitted. + * Finds the last accessed report for navigation fallbacks using the module-scoped + * Onyx subscriptions (non-UI, no view-based re-renders). `reportNameValuePairs` and + * `reports` let a caller pass its own subscribed collections and fall back to the + * module-scoped copies when omitted. Returns a minimal slice, not the full Report. */ function findLastAccessedReport( ignoreDomainRooms: boolean, From 05b073c59edc37f3bbde7ea08f36ad69eef4973b Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:02:38 +0530 Subject: [PATCH 022/485] refactor(report-utils): drop unused exports findLastAccessedReportSelector and FindLastAccessedReportOptions have no external consumers; tests exercise the public wrapper. Keep both module-private (selector still backs the wrapper, type still types its options param). --- src/libs/ReportUtils.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 532e47f551dd..253138de8466 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14315,7 +14315,6 @@ export { doesReportBelongToWorkspace, shouldEnableNegative, findLastAccessedReport, - findLastAccessedReportSelector, findSelfDMReportID, formatReportLastMessageText, generateReportID, @@ -14652,6 +14651,5 @@ export type { SelfDMParameters, OptimisticReportAction, ActionErrorsByTransaction, - FindLastAccessedReportOptions, LastAccessedReport, }; From f6ce10da4f78467e7b01b185255ae135ca0826b9 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:56:22 +0530 Subject: [PATCH 023/485] Encapsulate last accessed report lookup in useFindLastAccessedReport hook --- src/hooks/useFindLastAccessedReport.ts | 43 +++++++++++++++++++++ src/pages/inbox/ReportRouteParamHandler.tsx | 16 +++----- 2 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 src/hooks/useFindLastAccessedReport.ts diff --git a/src/hooks/useFindLastAccessedReport.ts b/src/hooks/useFindLastAccessedReport.ts new file mode 100644 index 000000000000..1e0244a3f859 --- /dev/null +++ b/src/hooks/useFindLastAccessedReport.ts @@ -0,0 +1,43 @@ +import useOnyx from '@hooks/useOnyx'; + +import {findLastAccessedReport, type LastAccessedReport} from '@libs/ReportUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {ResultMetadata} from 'react-native-onyx'; + +type UseFindLastAccessedReportOptions = { + /** Exclude domain rooms that are on the defaultRooms beta. */ + ignoreDomainRooms: boolean; + /** Prefer the policy admins room when one exists. */ + openOnAdminRoom?: boolean; + /** Report ID to exclude from the result (e.g. the report being left). */ + excludeReportID?: string; + /** When false, skip the lookup and return undefined without scanning reports. */ + enabled?: boolean; +}; + +type UseFindLastAccessedReportResult = { + lastAccessedReport: LastAccessedReport | undefined; + reportsMetadata: ResultMetadata | undefined; + reportNameValuePairsMetadata: ResultMetadata | undefined; +}; + +/** + * Resolves the last accessed report for navigation fallbacks from a view-scoped + * subscription, so callers do not thread the reports and name-value-pairs collections + * through themselves. The lookup runs inside the reports selector, so the component + * only re-renders when the resolved report changes. `enabled` short-circuits the + * selector when no lookup is needed. + */ +function useFindLastAccessedReport(options: UseFindLastAccessedReportOptions): UseFindLastAccessedReportResult { + const {ignoreDomainRooms, openOnAdminRoom = false, excludeReportID, enabled = true} = options; + const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [lastAccessedReport, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + selector: (reports) => (enabled ? findLastAccessedReport(ignoreDomainRooms, openOnAdminRoom, excludeReportID, reportNameValuePairs, reports) : undefined), + }); + return {lastAccessedReport, reportsMetadata, reportNameValuePairsMetadata}; +} + +export default useFindLastAccessedReport; +export type {UseFindLastAccessedReportOptions, UseFindLastAccessedReportResult}; diff --git a/src/pages/inbox/ReportRouteParamHandler.tsx b/src/pages/inbox/ReportRouteParamHandler.tsx index 96668abc100d..39880316b33a 100644 --- a/src/pages/inbox/ReportRouteParamHandler.tsx +++ b/src/pages/inbox/ReportRouteParamHandler.tsx @@ -1,16 +1,14 @@ -import useOnyx from '@hooks/useOnyx'; +import useFindLastAccessedReport from '@hooks/useFindLastAccessedReport'; import usePermissions from '@hooks/usePermissions'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import {findLastAccessedReport} from '@libs/ReportUtils'; import {isNumeric} from '@libs/ValidationUtils'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -28,17 +26,14 @@ function ReportRouteParamHandler() { const route = useRoute(); const navigation = useNavigation(); const {isBetaEnabled} = usePermissions(); - const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const shouldResolveReportID = !route.params.reportID; const ignoreDomainRooms = !isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS); const shouldOpenOnAdminRoom = 'openOnAdminRoom' in route.params && !!route.params.openOnAdminRoom; - // Subscribing to the reports collection (instead of relying on the module-scoped copy inside ReportUtils) - // makes this handler re-run once the reports finish loading, so a route that was created without a reportID - // recovers instead of staying stuck on the loading skeleton. Resolving inside the selector keeps that cheap: - // the route only re-renders when the resolved ID changes, and once one is set nothing is computed at all. - const [lastAccessedReportID, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { - selector: (reports) => (shouldResolveReportID ? findLastAccessedReport(ignoreDomainRooms, shouldOpenOnAdminRoom, undefined, reportNameValuePairs, reports)?.reportID : undefined), + const {lastAccessedReport, reportsMetadata, reportNameValuePairsMetadata} = useFindLastAccessedReport({ + ignoreDomainRooms, + openOnAdminRoom: shouldOpenOnAdminRoom, + enabled: shouldResolveReportID, }); useFocusEffect(() => { @@ -62,6 +57,7 @@ function ReportRouteParamHandler() { // It's possible that reports aren't fully loaded yet // in that case the reportID is undefined + const lastAccessedReportID = lastAccessedReport?.reportID; if (!lastAccessedReportID) { return; } From 3c15cfe806b6543561d3d81dabd26d3521a2168a Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:01:08 +0530 Subject: [PATCH 024/485] Trim verbose hook doc and drop stale pattern reference --- src/hooks/useFindLastAccessedReport.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/hooks/useFindLastAccessedReport.ts b/src/hooks/useFindLastAccessedReport.ts index 1e0244a3f859..022f66862b37 100644 --- a/src/hooks/useFindLastAccessedReport.ts +++ b/src/hooks/useFindLastAccessedReport.ts @@ -23,13 +23,7 @@ type UseFindLastAccessedReportResult = { reportNameValuePairsMetadata: ResultMetadata | undefined; }; -/** - * Resolves the last accessed report for navigation fallbacks from a view-scoped - * subscription, so callers do not thread the reports and name-value-pairs collections - * through themselves. The lookup runs inside the reports selector, so the component - * only re-renders when the resolved report changes. `enabled` short-circuits the - * selector when no lookup is needed. - */ +/** Resolves the last accessed report for navigation fallbacks from a view-scoped subscription. */ function useFindLastAccessedReport(options: UseFindLastAccessedReportOptions): UseFindLastAccessedReportResult { const {ignoreDomainRooms, openOnAdminRoom = false, excludeReportID, enabled = true} = options; const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); From aee6b5c852cd691f57ac0bb9600649c141af2753 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:53:22 +0530 Subject: [PATCH 025/485] Fix knip, eslint and typecheck on useFindLastAccessedReport --- src/hooks/useFindLastAccessedReport.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/hooks/useFindLastAccessedReport.ts b/src/hooks/useFindLastAccessedReport.ts index 022f66862b37..ba6bf8e8fc3a 100644 --- a/src/hooks/useFindLastAccessedReport.ts +++ b/src/hooks/useFindLastAccessedReport.ts @@ -1,10 +1,8 @@ -import useOnyx from '@hooks/useOnyx'; - -import {findLastAccessedReport, type LastAccessedReport} from '@libs/ReportUtils'; +import {findLastAccessedReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ResultMetadata} from 'react-native-onyx'; +import useOnyx from './useOnyx'; type UseFindLastAccessedReportOptions = { /** Exclude domain rooms that are on the defaultRooms beta. */ @@ -17,15 +15,8 @@ type UseFindLastAccessedReportOptions = { enabled?: boolean; }; -type UseFindLastAccessedReportResult = { - lastAccessedReport: LastAccessedReport | undefined; - reportsMetadata: ResultMetadata | undefined; - reportNameValuePairsMetadata: ResultMetadata | undefined; -}; - /** Resolves the last accessed report for navigation fallbacks from a view-scoped subscription. */ -function useFindLastAccessedReport(options: UseFindLastAccessedReportOptions): UseFindLastAccessedReportResult { - const {ignoreDomainRooms, openOnAdminRoom = false, excludeReportID, enabled = true} = options; +function useFindLastAccessedReport({ignoreDomainRooms, openOnAdminRoom = false, excludeReportID, enabled = true}: UseFindLastAccessedReportOptions) { const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [lastAccessedReport, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { selector: (reports) => (enabled ? findLastAccessedReport(ignoreDomainRooms, openOnAdminRoom, excludeReportID, reportNameValuePairs, reports) : undefined), @@ -34,4 +25,3 @@ function useFindLastAccessedReport(options: UseFindLastAccessedReportOptions): U } export default useFindLastAccessedReport; -export type {UseFindLastAccessedReportOptions, UseFindLastAccessedReportResult}; From 4c526ccf6d76b5d62a135e7e712b032c434bb723 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:55:36 +0530 Subject: [PATCH 026/485] Merge duplicate ReportUtils type imports in navigateAfterOnboardingTest --- tests/unit/navigateAfterOnboardingTest.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 898591e8ac2a..14e128adb05c 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,6 +1,5 @@ import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import type {LastAccessedReport} from '@libs/ReportUtils'; import type * as ReportUtils from '@libs/ReportUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -17,7 +16,7 @@ const ONBOARDING_ADMINS_CHAT_REPORT_ID = '1'; const ONBOARDING_POLICY_ID = '2'; const REPORT_ID = '3'; const USER_ID = '4'; -const mockFindLastAccessedReport = jest.fn>(); +const mockFindLastAccessedReport = jest.fn>(); const mockShouldOpenOnAdminRoom = jest.fn(() => false); const mockIsReportTopmostSplitNavigator = jest.fn(() => false); From 369410b8cb240b618448f09a82acd3a821a4f651 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:41:02 +0530 Subject: [PATCH 027/485] Pass last accessed report ID instead of NVP collection --- src/libs/actions/Report/index.ts | 24 +++++++++++------------- src/libs/actions/Task.ts | 13 +++++++------ src/pages/DynamicReportDetailsPage.tsx | 26 +++++++++++--------------- tests/actions/TaskTest.ts | 5 ++--- 4 files changed, 31 insertions(+), 37 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index a24dd55d2738..a031a353fc88 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -243,7 +243,6 @@ import type { Report, ReportAction, ReportAttributesDerivedValue, - ReportNameValuePairs, ReportUserIsTyping, SidePanelContext, Transaction, @@ -5019,18 +5018,18 @@ function navigateToMostRecentReport( introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, betas: OnyxEntry, - reportNameValuePairs?: OnyxCollection, + lastAccessedReportID?: string, ) { - const lastAccessedReportID = findLastAccessedReport(false, false, currentReport?.reportID, reportNameValuePairs)?.reportID; + const reportIDToOpen = lastAccessedReportID ?? findLastAccessedReport(false, false, currentReport?.reportID)?.reportID; - if (lastAccessedReportID) { + if (reportIDToOpen) { // Check if route exists for super wide RHP vs regular full screen report const topmostSuperWideRHP = Navigation.getTopmostSuperWideRHPReportID(); - if (lastAccessedReportID === topmostSuperWideRHP && !getIsNarrowLayout()) { + if (reportIDToOpen === topmostSuperWideRHP && !getIsNarrowLayout()) { Navigation.dismissToSuperWideRHP(); } else { - const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID); + const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(reportIDToOpen); Navigation.goBack(lastAccessedReportRoute); } } else { @@ -5060,9 +5059,8 @@ function getSearchThreadLeaveRoute(report: Report, activeRoute: string): Route | }); } -function getMostRecentReportID(currentReport: OnyxEntry, conciergeReportID: string | undefined, reportNameValuePairs?: OnyxCollection) { - const lastAccessedReportID = findLastAccessedReport(false, false, currentReport?.reportID, reportNameValuePairs)?.reportID; - return lastAccessedReportID ?? conciergeReportID; +function getMostRecentReportID(currentReport: OnyxEntry, conciergeReportID: string | undefined, lastAccessedReportID?: string) { + return lastAccessedReportID ?? findLastAccessedReport(false, false, currentReport?.reportID)?.reportID ?? conciergeReportID; } function joinRoom(report: OnyxEntry, currentUserAccountID: number) { @@ -5087,7 +5085,7 @@ function leaveGroupChat( introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, betas: OnyxEntry, - reportNameValuePairs?: OnyxCollection, + lastAccessedReportID?: string, ) { const reportID = report.reportID; // Use merge instead of set to avoid deleting the report too quickly, which could cause a brief "not found" page to appear. @@ -5138,7 +5136,7 @@ function leaveGroupChat( if (isSearchTopmostFullScreenRoute()) { Navigation.revealRouteBeforeDismissingModal(getReportRouteForCurrentContext({reportID})); } else { - navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas, reportNameValuePairs); + navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas, lastAccessedReportID); } API.write(WRITE_COMMANDS.LEAVE_GROUP_CHAT, {reportID}, {optimisticData, successData, failureData}); } @@ -5152,7 +5150,7 @@ function leaveRoom( isSelfTourViewed: boolean | undefined, betas: OnyxEntry, isWorkspaceMemberLeavingWorkspaceRoom = false, - reportNameValuePairs?: OnyxCollection, + lastAccessedReportID?: string, ) { const reportID = report.reportID; const isChatThread = isChatThreadReportUtils(report); @@ -5264,7 +5262,7 @@ function leaveRoom( return; } // In other cases, the report is deleted and we should move the user to another report. - navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas, reportNameValuePairs); + navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas, lastAccessedReportID); } function buildInviteToRoomOnyxData( diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 1d3e1d57c214..60cd9c515312 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -33,7 +33,7 @@ import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -90,7 +90,8 @@ type CreateTaskAndNavigateParams = { type DeleteTaskOptions = { ancestors?: ReportUtils.Ancestor[]; shouldNavigateBack?: boolean; - reportNameValuePairs?: OnyxCollection; + /** Fallback report ID when the deleted task has no parent report. */ + lastAccessedReportID?: string; }; /** @@ -1212,7 +1213,7 @@ function getNavigationUrlOnTaskDelete( report: OnyxEntry, conciergeReportID: string | undefined, reportActions: OnyxEntry, - reportNameValuePairs?: OnyxCollection, + lastAccessedReportID?: string, ): string | undefined { if (!report) { return undefined; @@ -1228,7 +1229,7 @@ function getNavigationUrlOnTaskDelete( } // If no parent report, try to navigate to most recent report - const mostRecentReportID = getMostRecentReportID(report, conciergeReportID, reportNameValuePairs); + const mostRecentReportID = getMostRecentReportID(report, conciergeReportID, lastAccessedReportID); if (mostRecentReportID) { return ROUTES.REPORT_WITH_ID.getRoute(mostRecentReportID); } @@ -1249,7 +1250,7 @@ function deleteTask( conciergeReportID: string | undefined, delegateEmail: string | undefined, reportActions: OnyxEntry, - {ancestors = [], shouldNavigateBack = true, reportNameValuePairs}: DeleteTaskOptions = {}, + {ancestors = [], shouldNavigateBack = true, lastAccessedReportID}: DeleteTaskOptions = {}, ) { if (!report) { return; @@ -1374,7 +1375,7 @@ function deleteTask( API.write(WRITE_COMMANDS.CANCEL_TASK, parameters, {optimisticData, successData, failureData}); notifyNewAction(report.reportID, undefined, true); - const urlToNavigateBack = shouldNavigateBack ? getNavigationUrlOnTaskDelete(report, conciergeReportID, reportActions, reportNameValuePairs) : undefined; + const urlToNavigateBack = shouldNavigateBack ? getNavigationUrlOnTaskDelete(report, conciergeReportID, reportActions, lastAccessedReportID) : undefined; if (urlToNavigateBack) { Navigation.goBack(); return urlToNavigateBack; diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index bc862af07749..6023f59dc6f5 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -26,6 +26,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDeleteTransactions from '@hooks/useDeleteTransactions'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useFindLastAccessedReport from '@hooks/useFindLastAccessedReport'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import useHasOutstandingChildTask from '@hooks/useHasOutstandingChildTask'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; @@ -215,7 +216,11 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const hasOutstandingChildTask = useHasOutstandingChildTask(report); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); - const [lastAccessedReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const {lastAccessedReport} = useFindLastAccessedReport({ + ignoreDomainRooms: false, + excludeReportID: report.reportID, + }); + const lastAccessedReportID = lastAccessedReport?.reportID; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [pendingDeleteMemberAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingDeleteMemberAccountIDsSelector}); @@ -402,22 +407,13 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report introSelected, isSelfTourViewed, betas, - lastAccessedReportNameValuePairs, + lastAccessedReportID, ); return; } const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin); - leaveRoom( - report, - currentUserPersonalDetails.accountID, - conciergeReportID, - introSelected, - isSelfTourViewed, - betas, - isWorkspaceMemberLeavingWorkspaceRoom, - lastAccessedReportNameValuePairs, - ); + leaveRoom(report, currentUserPersonalDetails.accountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom, lastAccessedReportID); }, [ isRootGroupChat, isPolicyEmployee, @@ -429,7 +425,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report introSelected, isSelfTourViewed, betas, - lastAccessedReportNameValuePairs, + lastAccessedReportID, ]); const showLastMemberLeavingModal = useCallback(async () => { @@ -1041,7 +1037,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report { ancestors, shouldNavigateBack: !taskDeleteBackTo, - reportNameValuePairs: lastAccessedReportNameValuePairs, + lastAccessedReportID, }, ); return; @@ -1098,7 +1094,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report conciergeReportID, delegateEmail, ancestors, - lastAccessedReportNameValuePairs, + lastAccessedReportID, reportActionsForOriginalReportID, moneyRequestReport, moneyRequestReportActions, diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index f120b3a3027b..539b3c6fd362 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -1238,12 +1238,11 @@ describe('actions/Task', () => { it('should return most recent report route when no parentReportID and getMostRecentReportID returns a value', () => { const taskReport = {...getFakeReport(), parentReportID: undefined}; const mostRecentReportID = 'recent_456'; - const reportNameValuePairs = {}; getMostRecentReportIDSpy.mockReturnValue(mostRecentReportID); - const result = getNavigationUrlOnTaskDelete(taskReport, 'concierge_123', undefined, reportNameValuePairs); + const result = getNavigationUrlOnTaskDelete(taskReport, 'concierge_123', undefined, mostRecentReportID); expect(result).toBe(`r/${mostRecentReportID}`); - expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(taskReport, 'concierge_123', reportNameValuePairs); + expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(taskReport, 'concierge_123', mostRecentReportID); }); it('should pass conciergeReportID to getMostRecentReportID as fallback', () => { From 6e9a90aba99adfadf9ae89b42bb1ff2452b32477 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:23:46 +0530 Subject: [PATCH 028/485] Derive last accessed report options inside the hook --- src/hooks/useFindLastAccessedReport.ts | 14 +++++++++----- src/pages/DynamicReportDetailsPage.tsx | 2 +- src/pages/inbox/ReportRouteParamHandler.tsx | 7 ------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/hooks/useFindLastAccessedReport.ts b/src/hooks/useFindLastAccessedReport.ts index ba6bf8e8fc3a..60501c148092 100644 --- a/src/hooks/useFindLastAccessedReport.ts +++ b/src/hooks/useFindLastAccessedReport.ts @@ -1,13 +1,14 @@ +import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoom'; import {findLastAccessedReport} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; type UseFindLastAccessedReportOptions = { - /** Exclude domain rooms that are on the defaultRooms beta. */ - ignoreDomainRooms: boolean; - /** Prefer the policy admins room when one exists. */ + /** Prefer the policy admins room when one exists. Defaults to the openOnAdminRoom URL param. */ openOnAdminRoom?: boolean; /** Report ID to exclude from the result (e.g. the report being left). */ excludeReportID?: string; @@ -16,10 +17,13 @@ type UseFindLastAccessedReportOptions = { }; /** Resolves the last accessed report for navigation fallbacks from a view-scoped subscription. */ -function useFindLastAccessedReport({ignoreDomainRooms, openOnAdminRoom = false, excludeReportID, enabled = true}: UseFindLastAccessedReportOptions) { +function useFindLastAccessedReport({openOnAdminRoom, excludeReportID, enabled = true}: UseFindLastAccessedReportOptions = {}) { + const {isBetaEnabled} = usePermissions(); + const ignoreDomainRooms = !isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS); + const shouldPreferAdminRoom = openOnAdminRoom ?? shouldOpenOnAdminRoom(); const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [lastAccessedReport, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { - selector: (reports) => (enabled ? findLastAccessedReport(ignoreDomainRooms, openOnAdminRoom, excludeReportID, reportNameValuePairs, reports) : undefined), + selector: (reports) => (enabled ? findLastAccessedReport(ignoreDomainRooms, shouldPreferAdminRoom, excludeReportID, reportNameValuePairs, reports) : undefined), }); return {lastAccessedReport, reportsMetadata, reportNameValuePairsMetadata}; } diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 6023f59dc6f5..97103be7a0de 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -217,8 +217,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); const {lastAccessedReport} = useFindLastAccessedReport({ - ignoreDomainRooms: false, excludeReportID: report.reportID, + openOnAdminRoom: false, }); const lastAccessedReportID = lastAccessedReport?.reportID; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); diff --git a/src/pages/inbox/ReportRouteParamHandler.tsx b/src/pages/inbox/ReportRouteParamHandler.tsx index 39880316b33a..6445e77a348a 100644 --- a/src/pages/inbox/ReportRouteParamHandler.tsx +++ b/src/pages/inbox/ReportRouteParamHandler.tsx @@ -1,5 +1,4 @@ import useFindLastAccessedReport from '@hooks/useFindLastAccessedReport'; -import usePermissions from '@hooks/usePermissions'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -8,7 +7,6 @@ import {isNumeric} from '@libs/ValidationUtils'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; -import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -25,14 +23,9 @@ type ReportScreenRoute = function ReportRouteParamHandler() { const route = useRoute(); const navigation = useNavigation(); - const {isBetaEnabled} = usePermissions(); const shouldResolveReportID = !route.params.reportID; - const ignoreDomainRooms = !isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS); - const shouldOpenOnAdminRoom = 'openOnAdminRoom' in route.params && !!route.params.openOnAdminRoom; const {lastAccessedReport, reportsMetadata, reportNameValuePairsMetadata} = useFindLastAccessedReport({ - ignoreDomainRooms, - openOnAdminRoom: shouldOpenOnAdminRoom, enabled: shouldResolveReportID, }); From 7dbc8b8c1512f18ad576898c136d1d859910bb5a Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Tue, 25 Aug 2026 21:34:53 +0000 Subject: [PATCH 029/485] Docs: self-DM (unreported) expenses can now be split Co-authored-by: Yauheni Horbach --- .../new-expensify/reports-and-expenses/Split-Expenses.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md index 4cf13a5c7091..df1b6f2ed927 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md @@ -12,7 +12,7 @@ Expensify makes it easy to split expenses by dividing a single transaction acros ## Who can split expenses -- **Submitters** can split any editable expense they've created, excluding unreported expenses that haven't been added to a report yet (but this functionality will be available soon) +- **Submitters** can split any editable expense they've created, including unreported expenses in their self-DM that haven't been added to a report yet - **Approvers** can split expenses assigned to them for approval - **Admins** can split any expense that hasn't been fully approved From 09ca249e488a307df3c51676c30f9a2d004ce45f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9=20=28via=20MelvinBot=29?= Date: Tue, 25 Aug 2026 21:41:33 +0000 Subject: [PATCH 030/485] Docs: reflect Rillet general availability after beta removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- docs/articles/Unlisted/Connect-to-Rillet.md | 2 -- .../Unlisted/Upcoming-Integrations-And-Accounting.md | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/articles/Unlisted/Connect-to-Rillet.md b/docs/articles/Unlisted/Connect-to-Rillet.md index 56347075fe18..e738866ba4ab 100644 --- a/docs/articles/Unlisted/Connect-to-Rillet.md +++ b/docs/articles/Unlisted/Connect-to-Rillet.md @@ -16,8 +16,6 @@ Once connected, the integration imports: - Departments, Projects, and Classes as tags. - Tax rates (when available). -The Rillet integration is currently in beta and is available to select customers. - --- ## Who can connect to Rillet diff --git a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md index e7c26fa841bb..9f5f650933de 100644 --- a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md +++ b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md @@ -1,7 +1,7 @@ --- title: Upcoming Features – Integrations & Accounting (New Expensify) -description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, NetSuite REST, and HR imports. Not for public search indexing. -keywords: upcoming, coming soon, planned, roadmap, Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, D365, NetSuite REST API, SuiteTalk, merge.dev, BambooHR, Workday, HiBob, HR import, HRIS, OAuth, Integration Server, accounting integration, AgentZero reference +description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Puzzle.io, Microsoft Dynamics 365 Business Central, NetSuite REST, and HR imports. Not for public search indexing. +keywords: upcoming, coming soon, planned, roadmap, Puzzle.io, Microsoft Dynamics 365 Business Central, D365, NetSuite REST API, SuiteTalk, merge.dev, BambooHR, Workday, HiBob, HR import, HRIS, OAuth, Integration Server, accounting integration, AgentZero reference noindex: true sitemap: false internalScope: For AgentZero and internal tooling only. Use this page to give accurate, forward-looking answers about accounting and integration features that are planned but not yet generally available. Do not expose this URL in user-facing help. @@ -11,7 +11,6 @@ internalScope: For AgentZero and internal tooling only. Use this page to give ac This page is an **AgentZero reference** for accounting and integration features that are planned or in development for New Expensify. Use it to answer "is this coming soon?" questions accurately. These items are subject to change. -- **Rillet integration** – Native accounting integration with Rillet, a modern general ledger for US-based companies, enabling direct sync of expenses and reports. - **Puzzle.io integration** – Native accounting integration with Puzzle.io, adding support for startups and SMBs using Puzzle as their accounting system. - **Microsoft Dynamics 365 Business Central integration** – Native integration with Microsoft Dynamics 365 Business Central, covering a significant share of SMB and mid-market accounting users who currently rely on manual CSV/API workarounds. - **NetSuite REST API migration** – Move the NetSuite integration from the legacy SuiteTalk SOAP API to the modern REST API, improving reliability, sync speed, and long-term supportability. From 9fe0c49de9480e262df21af89398d541c8539727 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:19:37 -1000 Subject: [PATCH 031/485] Update duplication process details in documentation Clarified the behavior of duplicating reported and unreported expenses, and removed outdated comment about expense types. --- .../reports-and-expenses/How-to-Duplicate-an-Expense.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md index 9e4920aacdfa..a98b889237a0 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md @@ -7,17 +7,12 @@ internalScope: Audience is all members. Covers how to duplicate a single expense # How to duplicate an expense -You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. A reported expense is copied onto your primary workspace, while an Unreported expense is copied as an Unreported expense. +You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. An expense that is on a report is copied onto your primary workspace, while an Unreported expense is copied onto your personal space. This is useful when you need to create similar expenses quickly without re-entering all the details manually. You can duplicate a single expense or select multiple expenses and duplicate them all at once. **Note:** Card expenses cannot be duplicated. Only cash, distance, and per diem expenses are eligible to be duplicated. - - --- ## Who can duplicate an expense @@ -70,7 +65,7 @@ Purpose: Helps the user identify the bulk duplicate option in the dropdown. The duplicate expense keeps the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included. -The duplicate matches the reported state of the original. If the original is a reported expense, the copy is created on your primary workspace. If the original is an Unreported expense, the copy is also created as an Unreported expense instead of being added to a report. +The duplicate matches the reported state of the original. If the original expense is on a report, the copy is created on your primary workspace. If the original is an Unreported expense, the copy is created on your personal space and is not added to a report. If the original expense's coding (such as categories or tags) is not available on your primary workspace, the duplicate expense will still be created. You will be prompted to correct the coding on the expense. From 363564f50d64908a24c3d81ab0157c502a6d4bbe Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 11:29:07 +0700 Subject: [PATCH 032/485] refactor: thread currentAccountID via createOptionFromReport --- .../Search/FilterComponents/InSelector.tsx | 3 +- .../Search/SearchRouter/SearchRouter.tsx | 2 + src/libs/OptionsListUtils/index.ts | 3 + tests/unit/OptionsListUtilsTest.tsx | 70 +++++++++++++++++++ tests/unit/ReportActionsUtilsTest.ts | 2 +- 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index 7f458edfa5ab..bc50de629d34 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -92,6 +92,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle conciergeReportID, reportAttributesDerived, isTrackIntentUser, + currentUserAccountID, }), ), isSelected, @@ -102,7 +103,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle const alternateText = getAlternateText( report, {}, - {dateFnsLocale, isReportArchived, personalDetails, policy, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID, isTrackIntentUser}, + {dateFnsLocale, isReportArchived, personalDetails, policy, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID, isTrackIntentUser, currentUserAccountID}, ); return {...report, alternateText}; }; diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 7c3733131375..30726c7bc03a 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -229,6 +229,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla showPersonalDetails: isOneOnOneChat(contextualReport), }, isTrackIntentUser, + currentUserAccountID, }); reportForContextualSearch = option; } @@ -299,6 +300,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla reportAttributes, isTrackIntentUser, dateFnsLocale, + currentUserAccountID, ], ); diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 11e7f67d225a..9982da1b083a 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1967,6 +1967,7 @@ type CreateOptionFromReportParams = { policyTags?: OnyxEntry; visibleReportActionsData?: VisibleReportActionsDerivedValue; isTrackIntentUser?: boolean; + currentUserAccountID: number; }; function createOptionFromReport({ @@ -1982,6 +1983,7 @@ function createOptionFromReport({ policyTags, visibleReportActionsData = {}, isTrackIntentUser, + currentUserAccountID, }: CreateOptionFromReportParams) { const accountIDs = getParticipantsAccountIDsForDisplay(report); @@ -2001,6 +2003,7 @@ function createOptionFromReport({ visibleReportActionsData, sortedActions, isTrackIntentUser, + currentUserAccountID, }), }; } diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 953fdeb44c98..5e7fbe274ac6 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -10842,6 +10842,7 @@ describe('OptionsListUtils', () => { // When the threaded conciergeReportID matches the report const conciergeOption = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, @@ -10854,6 +10855,7 @@ describe('OptionsListUtils', () => { // And an identical report with a non-matching conciergeReportID is not treated as Concierge const regularOption = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, @@ -10883,6 +10885,7 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, @@ -10915,6 +10918,7 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: true, @@ -10946,6 +10950,7 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, @@ -10977,6 +10982,7 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, @@ -11009,6 +11015,7 @@ describe('OptionsListUtils', () => { const config = {showPersonalDetails: true}; const result = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, @@ -11046,6 +11053,7 @@ describe('OptionsListUtils', () => { const roomOption = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, @@ -11055,6 +11063,7 @@ describe('OptionsListUtils', () => { }); const personalDetailsOption = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, @@ -11068,6 +11077,66 @@ describe('OptionsListUtils', () => { // With showPersonalDetails the option is named after the other participant (account 1 in PERSONAL_DETAILS). expect(personalDetailsOption.text).toBe('Mister Fantastic'); }); + + it('should use the passed currentUserAccountID for the reimbursed preview wording', async () => { + // Dedicated reportID: module-level report-action caches survive Onyx.clear(), so writing + // REPORT_ACTIONS for a shared reportID would poison later tests that reuse it. + const reimbursedReportID = 'create-option-reimbursed-1'; + const ownerAccountID = 42; + const submitterLogin = 'submitter@expensify.com'; + + // Given an expense report whose last action is a Fast_ACH reimbursement paid to account 42 + const report: Report = { + reportID: reimbursedReportID, + reportName: 'Expense Report', + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID, + lastMessageText: '', + lastActionType: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, + lastVisibleActionCreated: '2024-01-01 10:00:00.000', + }; + const reimbursedAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, + reportActionID: 'reimbursed-1', + created: '2024-01-01 10:00:00.000', + actorAccountID: 3, + originalMessage: { + paymentMethod: 'Fast_ACH', + creditBankAccountLast4: '1111', + expectedDate: '2025-03-15', + }, + message: [{type: 'COMMENT', html: 'reimbursed', text: 'reimbursed'}], + } as unknown as ReportAction; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reimbursedReportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reimbursedReportID}`, {[reimbursedAction.reportActionID]: reimbursedAction}); + await waitForBatchedUpdates(); + + const personalDetailsWithSubmitter = { + ...PERSONAL_DETAILS, + [ownerAccountID]: {accountID: ownerAccountID, login: submitterLogin, displayName: 'Submitter'}, + }; + const params = { + dateFnsLocale: undefined, + report, + personalDetails: personalDetailsWithSubmitter, + privateIsArchived: undefined, + policy: undefined, + sortedActions: {[reimbursedReportID]: [reimbursedAction]}, + conciergeReportID: undefined, + config: {showChatPreviewLine: true}, + }; + + // When the option is built for the report owner + const ownOption = createOptionFromReport({...params, currentUserAccountID: ownerAccountID}); + + // And for somebody else + const otherOption = createOptionFromReport({...params, currentUserAccountID: 999}); + + // Then the wording follows the passed currentUserAccountID, not the module-level session value + expect(ownOption.alternateText).toContain('your bank account ending in 1111'); + expect(otherOption.alternateText).toContain(`${submitterLogin}'s bank account ending in 1111`); + }); }); describe('createFilteredOptionList', () => { @@ -11539,6 +11608,7 @@ describe('OptionsListUtils', () => { const result = createOptionFromReport({ dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, report, personalDetails: PERSONAL_DETAILS, privateIsArchived: undefined, diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index c2d3286f94d9..f3707180b80f 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -5368,7 +5368,7 @@ describe('ReportActionsUtils', () => { creditedAmount: 8050, }); - const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, undefined, undefined, convertToDisplayString); + const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, undefined, undefined, convertToDisplayString, CONST.DEFAULT_NUMBER_ID); // Then we describe the payment without an amount rather than guessing a currency expect(result).toBe( From 3d4efce131acaf107159e51708b1a0e8b8202a99 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 11:35:34 +0700 Subject: [PATCH 033/485] fix: add test --- tests/unit/OptionsListUtilsTest.tsx | 150 +++++++++++++++++++--------- 1 file changed, 102 insertions(+), 48 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 5e7fbe274ac6..2e999a4eb38a 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -11078,64 +11078,118 @@ describe('OptionsListUtils', () => { expect(personalDetailsOption.text).toBe('Mister Fantastic'); }); - it('should use the passed currentUserAccountID for the reimbursed preview wording', async () => { - // Dedicated reportID: module-level report-action caches survive Onyx.clear(), so writing - // REPORT_ACTIONS for a shared reportID would poison later tests that reuse it. - const reimbursedReportID = 'create-option-reimbursed-1'; - const ownerAccountID = 42; - const submitterLogin = 'submitter@expensify.com'; + describe('reimbursed report preview', () => { + const SUBMITTER_LOGIN = 'submitter@expensify.com'; + + /** + * Builds an expense report whose last visible action is a REIMBURSED action, writes it to Onyx and + * returns the params for `createOptionFromReport`. Each case uses its own reportID because the + * module-level report-action caches survive `Onyx.clear()`, so a shared ID would leak across tests. + */ + const setUpReimbursedReport = async (reportID: string, ownerAccountID: number, originalMessage: Record) => { + const report: Report = { + reportID, + reportName: 'Expense Report', + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID, + // Empty so the preview falls through to getLastMessageTextForReport instead of reusing lastMessageText. + lastMessageText: '', + lastActionType: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, + lastVisibleActionCreated: '2024-01-01 10:00:00.000', + }; + const reimbursedAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, + reportActionID: `reimbursed-${reportID}`, + created: '2024-01-01 10:00:00.000', + actorAccountID: 3, + originalMessage, + message: [{type: 'COMMENT', html: 'reimbursed', text: 'reimbursed'}], + } as unknown as ReportAction; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reimbursedAction.reportActionID]: reimbursedAction}); + await waitForBatchedUpdates(); - // Given an expense report whose last action is a Fast_ACH reimbursement paid to account 42 - const report: Report = { - reportID: reimbursedReportID, - reportName: 'Expense Report', - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID, - lastMessageText: '', - lastActionType: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, - lastVisibleActionCreated: '2024-01-01 10:00:00.000', + return { + dateFnsLocale: undefined, + report, + personalDetails: {...PERSONAL_DETAILS, [ownerAccountID]: {accountID: ownerAccountID, login: SUBMITTER_LOGIN, displayName: 'Submitter'}}, + privateIsArchived: undefined, + policy: undefined, + sortedActions: {[reportID]: [reimbursedAction]}, + conciergeReportID: undefined, + config: {showChatPreviewLine: true}, + }; }; - const reimbursedAction = { - actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, - reportActionID: 'reimbursed-1', - created: '2024-01-01 10:00:00.000', - actorAccountID: 3, - originalMessage: { + + it('should address the payee directly only in the preview shown to them for a Fast ACH reimbursement', async () => { + // Given an expense report whose last action is a Fast_ACH reimbursement paid to account 42 + const ownerAccountID = 42; + const params = await setUpReimbursedReport('create-option-reimbursed-fast-ach', ownerAccountID, { paymentMethod: 'Fast_ACH', creditBankAccountLast4: '1111', expectedDate: '2025-03-15', - }, - message: [{type: 'COMMENT', html: 'reimbursed', text: 'reimbursed'}], - } as unknown as ReportAction; + }); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reimbursedReportID}`, report); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reimbursedReportID}`, {[reimbursedAction.reportActionID]: reimbursedAction}); - await waitForBatchedUpdates(); + // When the option is built for the report owner, and for somebody else + const ownOption = createOptionFromReport({...params, currentUserAccountID: ownerAccountID}); + const otherOption = createOptionFromReport({...params, currentUserAccountID: 999}); - const personalDetailsWithSubmitter = { - ...PERSONAL_DETAILS, - [ownerAccountID]: {accountID: ownerAccountID, login: submitterLogin, displayName: 'Submitter'}, - }; - const params = { - dateFnsLocale: undefined, - report, - personalDetails: personalDetailsWithSubmitter, - privateIsArchived: undefined, - policy: undefined, - sortedActions: {[reimbursedReportID]: [reimbursedAction]}, - conciergeReportID: undefined, - config: {showChatPreviewLine: true}, - }; + // Then the wording follows the passed currentUserAccountID + expect(ownOption.alternateText).toContain('your bank account ending in 1111'); + expect(otherOption.alternateText).toContain(`${SUBMITTER_LOGIN}'s bank account ending in 1111`); + }); - // When the option is built for the report owner - const ownOption = createOptionFromReport({...params, currentUserAccountID: ownerAccountID}); + it('should address the payee directly only in the preview shown to them for a StripeConnect reimbursement', async () => { + // Given an expense report reimbursed to account 42 through StripeConnect + const ownerAccountID = 42; + const params = await setUpReimbursedReport('create-option-reimbursed-stripe', ownerAccountID, { + paymentMethod: 'StripeConnect', + creditBankAccountLast4: '2222', + stripePaymentType: 'bank_transfer', + }); + + // When the option is built for the report owner, and for somebody else + const ownOption = createOptionFromReport({...params, currentUserAccountID: ownerAccountID}); + const otherOption = createOptionFromReport({...params, currentUserAccountID: 999}); - // And for somebody else - const otherOption = createOptionFromReport({...params, currentUserAccountID: 999}); + // Then the wording follows the passed currentUserAccountID + expect(ownOption.alternateText).toContain('your bank account ending in 2222'); + expect(otherOption.alternateText).toContain(`${SUBMITTER_LOGIN}'s bank account ending in 2222`); + }); - // Then the wording follows the passed currentUserAccountID, not the module-level session value - expect(ownOption.alternateText).toContain('your bank account ending in 1111'); - expect(otherOption.alternateText).toContain(`${submitterLogin}'s bank account ending in 1111`); + it('should build the preview for the account it is given rather than the signed-in session account', async () => { + // Given a report owned by the signed-in user (the session accountID written in beforeEach) + const params = await setUpReimbursedReport('create-option-reimbursed-session', CURRENT_USER_ACCOUNT_ID, { + paymentMethod: 'Fast_ACH', + creditBankAccountLast4: '3333', + expectedDate: '2025-03-15', + }); + + // When the option is built for a different account than the one in the session + const option = createOptionFromReport({...params, currentUserAccountID: 999}); + + // Then the passed account wins over the module-level session value, so the owner is named explicitly + expect(option.alternateText).toContain(`${SUBMITTER_LOGIN}'s bank account ending in 3333`); + expect(option.alternateText).not.toContain('your bank account'); + }); + + it('should build the same ACH preview for every viewer', async () => { + // Given an expense report reimbursed with plain ACH, whose message never names the payee + const params = await setUpReimbursedReport('create-option-reimbursed-ach', 42, { + paymentMethod: 'ACH', + debitBankAccountLast4: '9999', + creditBankAccountLast4: '5678', + }); + + // When the option is built for the report owner, and for somebody else + const ownOption = createOptionFromReport({...params, currentUserAccountID: 42}); + const otherOption = createOptionFromReport({...params, currentUserAccountID: 999}); + + // Then both previews are identical + expect(ownOption.alternateText).toBe(otherOption.alternateText); + expect(ownOption.alternateText).toContain('to the bank account ending in 5678'); + }); }); }); From bd17d749dce34d6b3b7456cfacf2206f9fce1df3 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 11:36:26 +0700 Subject: [PATCH 034/485] fix: revert redundant change --- tests/unit/ReportActionsUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index f3707180b80f..c2d3286f94d9 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -5368,7 +5368,7 @@ describe('ReportActionsUtils', () => { creditedAmount: 8050, }); - const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, undefined, undefined, convertToDisplayString, CONST.DEFAULT_NUMBER_ID); + const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, undefined, undefined, convertToDisplayString); // Then we describe the payment without an amount rather than guessing a currency expect(result).toBe( From c012537287c85ec07d1e689915fb876a08a69080 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 11:46:36 +0700 Subject: [PATCH 035/485] fix: lint --- tests/unit/OptionsListUtilsTest.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 2e999a4eb38a..a6ad7a0f57ef 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -93,6 +93,7 @@ import type {PersonalDetails, Policy, Report, ReportAction, ReportNameValuePairs import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {Participant} from '@src/types/onyx/IOU'; import type Login from '@src/types/onyx/Login'; +import type {OriginalMessageReimbursed} from '@src/types/onyx/OriginalMessage'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -11086,7 +11087,7 @@ describe('OptionsListUtils', () => { * returns the params for `createOptionFromReport`. Each case uses its own reportID because the * module-level report-action caches survive `Onyx.clear()`, so a shared ID would leak across tests. */ - const setUpReimbursedReport = async (reportID: string, ownerAccountID: number, originalMessage: Record) => { + const setUpReimbursedReport = async (reportID: string, ownerAccountID: number, originalMessage: OriginalMessageReimbursed) => { const report: Report = { reportID, reportName: 'Expense Report', @@ -11097,14 +11098,14 @@ describe('OptionsListUtils', () => { lastActionType: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, lastVisibleActionCreated: '2024-01-01 10:00:00.000', }; - const reimbursedAction = { + const reimbursedAction: ReportAction = { actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, reportActionID: `reimbursed-${reportID}`, created: '2024-01-01 10:00:00.000', actorAccountID: 3, originalMessage, message: [{type: 'COMMENT', html: 'reimbursed', text: 'reimbursed'}], - } as unknown as ReportAction; + }; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reimbursedAction.reportActionID]: reimbursedAction}); From f65419d542e930cd74e5c3bc63a0cb23eb1d737f Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 12:20:17 +0700 Subject: [PATCH 036/485] refactor getUserToInviteOption --- src/libs/OptionsListUtils/index.ts | 3 +++ src/libs/OptionsListUtils/types.ts | 2 ++ tests/unit/OptionsListUtilsTest.tsx | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 11e7f67d225a..ee2607cafd70 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2316,6 +2316,7 @@ function getUserToInviteOption({ countryCode = CONST.DEFAULT_COUNTRY_CODE, loginList = {}, currentUserEmail, + currentUserAccountID, visibleReportActionsData = {}, }: GetUserToInviteConfig & {visibleReportActionsData?: VisibleReportActionsDerivedValue; dateFnsLocale: DateFnsLocale | undefined}): SearchOptionData | null { if (!searchValue) { @@ -2358,6 +2359,7 @@ function getUserToInviteOption({ conciergeReportID: undefined, config: {showChatPreviewLine}, visibleReportActionsData, + currentUserAccountID, }); userToInvite.isOptimisticAccount = true; userToInvite.login = searchValue; @@ -3030,6 +3032,7 @@ function getValidOptions( excludeLogins: loginsToExclude, shouldAcceptName, searchInputValue, + currentUserAccountID, }, ); } diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index bb3a6d6f6702..4a9b9a13ca01 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -303,6 +303,7 @@ type GetUserToInviteConfig = { countryCode?: number; loginList: OnyxEntry; currentUserEmail: string; + currentUserAccountID?: number; } & Pick; type MemberForList = { @@ -347,6 +348,7 @@ type FilterUserToInviteConfig = Pick; + currentUserAccountID?: number; }; type OrderOptionsConfig = diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 953fdeb44c98..93e518b5bd69 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -6140,6 +6140,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, report, privateIsArchived: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(result.reportID).toBe(reportID); @@ -7687,6 +7688,7 @@ describe('OptionsListUtils', () => { isReportArchived: false, lastAction: addCommentAction, currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); // Then the mention should fall back to the hidden placeholder @@ -8039,8 +8041,8 @@ describe('OptionsListUtils', () => { lastActorDetails: null, policy: undefined, isReportArchived: true, - currentUserLogin: '', + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toContain('UnavailableMarker'); From 70168a19283f733cca40ac99a1ea4c08ee59f7df Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 12:21:59 +0700 Subject: [PATCH 037/485] fix: refactor filterOptions --- src/libs/OptionsListUtils/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index ee2607cafd70..7f81c1270414 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -3535,6 +3535,7 @@ function filterOptions( // `config` is optional, so the required locale has to be set explicitly rather than relying on the spread. dateFnsLocale: config?.dateFnsLocale, searchInputValue: searchInputValueForInvite, + currentUserAccountID, }, ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); From 51e16ab4b1fc73e45f359baa4d9616f3dd61704b Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 18:22:05 +0700 Subject: [PATCH 038/485] fix: refactor getUserToInviteOption --- src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts | 1 + src/pages/ReportSubmitToContent.tsx | 1 + tests/unit/OptionsListUtilsTest.tsx | 3 +++ 3 files changed, 5 insertions(+) diff --git a/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts index 5dafb8224daa..b2dc86f0e4f1 100644 --- a/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts +++ b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts @@ -69,6 +69,7 @@ function useGroupChatDraftParticipantSync( personalDetails: allPersonalDetails, loginList, currentUserEmail, + currentUserAccountID, }); if (option) { result.push({...option, isSelected: true}); diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 5cd1f83218db..b5f1da965190 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -215,6 +215,7 @@ function ReportSubmitToContent({ personalDetails, loginList, currentUserEmail: currentUserDetails.email ?? '', + currentUserAccountID: currentUserDetails.accountID, countryCode, selectedOptions: [], loginsToExclude: CONST.EXPENSIFY_EMAILS_OBJECT, diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 93e518b5bd69..ac5d81f7ff75 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -10528,6 +10528,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(result).toBeNull(); }); @@ -10540,6 +10541,7 @@ describe('OptionsListUtils', () => { shouldAcceptName: true, loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(result).not.toBeNull(); expect(result?.login).toBe('Jeff Amazon'); @@ -10640,6 +10642,7 @@ describe('OptionsListUtils', () => { searchValue: 'newuser@example.com', loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, personalDetails: PERSONAL_DETAILS, }); From ec8cf86f95fcfc956bf2c24ac4be50ae7b1ec94f Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 18:52:50 +0700 Subject: [PATCH 039/485] fix: refactor getUserToInviteOption --- .../Search/FilterComponents/InSelector.tsx | 1 + src/libs/OptionsListUtils/index.ts | 7 ++++--- src/libs/OptionsListUtils/types.ts | 4 ++-- tests/unit/OptionsListUtilsTest.tsx | 12 +++++++++++- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index 7f458edfa5ab..d1a434a1bdde 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -139,6 +139,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle dateFnsLocale, selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + currentUserAccountID, }); const sections: SelectionListSections = []; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 7f81c1270414..159604357231 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -3461,9 +3461,9 @@ function filterUserToInvite( currentUserEmail: string, personalDetails: OnyxEntry, countryCode: number = CONST.DEFAULT_COUNTRY_CODE, - config?: FilterUserToInviteConfig, + config: FilterUserToInviteConfig, ): SearchOptionData | null { - const {canInviteUser = true, excludeLogins = {}, dateFnsLocale} = config ?? {}; + const {canInviteUser = true, excludeLogins = {}, dateFnsLocale, currentUserAccountID} = config; if (!canInviteUser) { return null; } @@ -3484,7 +3484,6 @@ function filterUserToInvite( ...excludeLogins, }; return getUserToInviteOption({ - dateFnsLocale, searchValue, personalDetails, loginsToExclude, @@ -3492,6 +3491,8 @@ function filterUserToInvite( loginList, currentUserEmail, ...config, + dateFnsLocale, + currentUserAccountID, }); } diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 4a9b9a13ca01..3e43d3bc950d 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -303,7 +303,7 @@ type GetUserToInviteConfig = { countryCode?: number; loginList: OnyxEntry; currentUserEmail: string; - currentUserAccountID?: number; + currentUserAccountID: number; } & Pick; type MemberForList = { @@ -348,7 +348,7 @@ type FilterUserToInviteConfig = Pick; - currentUserAccountID?: number; + currentUserAccountID: number; }; type OrderOptionsConfig = diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index ac5d81f7ff75..410085903e84 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3251,6 +3251,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value and sortByReportTypeInSearch param const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, sortByReportTypeInSearch: true, }); @@ -3358,6 +3359,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value and sortByReportTypeInSearch param const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS_WITH_PERIODS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, sortByReportTypeInSearch: true, }); @@ -3528,6 +3530,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value and excluded logins list const filterOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, }); @@ -3556,6 +3559,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value and excludeLogins const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, }); @@ -3584,6 +3588,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value and maxRecentReportsToShow set to 2 const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, maxRecentReportsToShow: 2, }); @@ -3594,6 +3599,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value and maxRecentReportsToShow set to 0 const limitToZeroOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, maxRecentReportsToShow: 0, }); @@ -4267,6 +4273,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a plain text name (not email or phone) without shouldAcceptName const filteredOptions = filterAndOrderOptions(options, 'Jeff Amazon', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, shouldAcceptName: false, }); @@ -4291,6 +4298,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a plain text name (not email or phone) with shouldAcceptName const filteredOptions = filterAndOrderOptions(options, 'Jeff', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, shouldAcceptName: true, }); @@ -4334,6 +4342,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that matches an email const filteredOptions = filterAndOrderOptions(options, 'peterparker@expensify.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, sortByReportTypeInSearch: true, }); @@ -4361,6 +4370,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that matches both reports and personal details and maxRecentReportsToShow param const filteredOptions = filterAndOrderOptions(options, '.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, maxRecentReportsToShow: 5, }); @@ -4459,7 +4469,7 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS_WITH_PERIODS, - {dateFnsLocale: undefined, sortByReportTypeInSearch: true}, + {dateFnsLocale: undefined, sortByReportTypeInSearch: true, currentUserAccountID: CURRENT_USER_ACCOUNT_ID}, ); // Then only one report should be returned From 08299672acde7af5d7c55b6a12a7eda70334f860 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 19:02:17 +0700 Subject: [PATCH 040/485] fix: lint --- src/libs/OptionsListUtils/index.ts | 7 +++---- src/pages/ReportSubmitToContent.tsx | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 159604357231..c818f471252a 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -3026,7 +3026,6 @@ function getValidOptions( loginList, currentUserEmail, personalDetails, - countryCode, { dateFnsLocale, excludeLogins: loginsToExclude, @@ -3034,6 +3033,7 @@ function getValidOptions( searchInputValue, currentUserAccountID, }, + countryCode, ); } @@ -3460,8 +3460,8 @@ function filterUserToInvite( loginList: OnyxEntry, currentUserEmail: string, personalDetails: OnyxEntry, - countryCode: number = CONST.DEFAULT_COUNTRY_CODE, config: FilterUserToInviteConfig, + countryCode: number = CONST.DEFAULT_COUNTRY_CODE, ): SearchOptionData | null { const {canInviteUser = true, excludeLogins = {}, dateFnsLocale, currentUserAccountID} = config; if (!canInviteUser) { @@ -3530,14 +3530,13 @@ function filterOptions( loginList, currentUserEmail, personalDetailsCollection, - countryCode, { ...config, - // `config` is optional, so the required locale has to be set explicitly rather than relying on the spread. dateFnsLocale: config?.dateFnsLocale, searchInputValue: searchInputValueForInvite, currentUserAccountID, }, + countryCode, ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index b5f1da965190..25d2e7c942f3 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -232,7 +232,7 @@ function ReportSubmitToContent({ keyForList: `nonWorkspace:${login}`, isSelected: managerEmail.trim().toLowerCase() === login.trim().toLowerCase(), }; - }, [countryCode, currentUserDetails.email, searchTerm, filteredWorkspaceMembers.length, loginList, managerEmail, personalDetails, dateFnsLocale]); + }, [countryCode, currentUserDetails.email, currentUserDetails.accountID, searchTerm, filteredWorkspaceMembers.length, loginList, managerEmail, personalDetails, dateFnsLocale]); const submitToSelectionData = useMemo(() => { if (!nonWorkspaceInviteRow) { From d1989204f20cab2beafa9050ef9b98a702e31e12 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 29 Aug 2026 19:14:10 +0700 Subject: [PATCH 041/485] fix: add test --- tests/unit/OptionsListUtilsTest.tsx | 106 ++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 410085903e84..58e058dea5d3 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -10556,6 +10556,112 @@ describe('OptionsListUtils', () => { expect(result).not.toBeNull(); expect(result?.login).toBe('Jeff Amazon'); }); + + it('should return a valid invite option for a new email', () => { + const result = getUserToInviteOption({ + dateFnsLocale: undefined, + searchValue: 'newuser@example.com', + personalDetails: PERSONAL_DETAILS, + loginList: {}, + currentUserEmail: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + }); + + expect(result).not.toBeNull(); + expect(result?.login).toBe('newuser@example.com'); + expect(result?.isOptimisticAccount).toBe(true); + }); + + it('should return null when searchValue is empty', () => { + const result = getUserToInviteOption({ + dateFnsLocale: undefined, + searchValue: '', + personalDetails: PERSONAL_DETAILS, + loginList: {}, + currentUserEmail: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + }); + + expect(result).toBeNull(); + }); + + it('should return null when searching for current user email', () => { + const result = getUserToInviteOption({ + dateFnsLocale: undefined, + searchValue: CURRENT_USER_EMAIL, + personalDetails: PERSONAL_DETAILS, + loginList: {}, + currentUserEmail: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + }); + + expect(result).toBeNull(); + }); + }); + + describe('filterAndOrderOptions with userToInvite', () => { + it('should produce userToInvite when searching for a new email with config', () => { + const {options} = getValidOptions( + {reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + {dateFnsLocale: undefined}, + translateLocal, + ); + + const filteredOptions = filterAndOrderOptions(options, 'newuser@example.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { + dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + }); + + expect(filteredOptions.userToInvite).not.toBeNull(); + expect(filteredOptions.userToInvite?.login).toBe('newuser@example.com'); + expect(filteredOptions.userToInvite?.isOptimisticAccount).toBe(true); + }); + + it('should produce userToInvite when searching for a new email without config', () => { + const {options} = getValidOptions( + {reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + {dateFnsLocale: undefined}, + translateLocal, + ); + + const filteredOptions = filterAndOrderOptions(options, 'anotheruser@example.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + + expect(filteredOptions.userToInvite).not.toBeNull(); + expect(filteredOptions.userToInvite?.login).toBe('anotheruser@example.com'); + }); + + it('should not produce userToInvite when search matches current user', () => { + const {options} = getValidOptions( + {reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + {dateFnsLocale: undefined}, + translateLocal, + ); + + const filteredOptions = filterAndOrderOptions(options, CURRENT_USER_EMAIL, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { + dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + }); + + expect(filteredOptions.userToInvite).toBeNull(); + }); }); describe('reports parameter functionality', () => { From e5d2738c5880f7e13c31b19554d8bfe7e11361c3 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:05:51 +0530 Subject: [PATCH 042/485] Add paginated tag filters with server-side search --- src/CONST/index.ts | 1 + src/ONYXKEYS.ts | 2 + .../Search/FilterComponents/MultiSelect.tsx | 24 +++++-- .../Search/FilterComponents/TagSelector.tsx | 48 +++++++------ src/hooks/useSearchTagFilters.ts | 70 +++++++++++++++++++ .../OpenSearchTagFiltersPageParams.ts | 12 ++++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/actions/Search.ts | 15 +++- src/types/onyx/PolicyTag.ts | 14 +++- src/types/onyx/Response.ts | 6 ++ src/types/onyx/index.ts | 3 +- 12 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 src/hooks/useSearchTagFilters.ts create mode 100644 src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5cb228861289..a31360b911a5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6887,6 +6887,7 @@ const CONST = { SEARCH: { RESULTS_PAGE_SIZE: 50, + TAG_FILTER_PAGE_SIZE: 200, EXITING_ANIMATION_DURATION: 200, ME: 'me', DATA_TYPES: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index c072d66ac9c0..66c720676a93 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -889,6 +889,7 @@ const ONYXKEYS = { POLICY_CATEGORIES_DRAFT: 'policyCategoriesDraft_', POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', + SEARCH_POLICY_TAGS: 'searchPolicyTags_', POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', POLICY_RECENTLY_USED_DESTINATIONS: 'nvp_recentlyUsedDestinations_', // Whether the policy's connection data was attempted to be fetched in @@ -1490,6 +1491,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagLists; + [ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS]: OnyxTypes.SearchPolicyTags; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED]: boolean; diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 03f00ce98341..bb5a49b264f2 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -18,7 +18,7 @@ import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {ReactNode} from 'react'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import ListFilterView from './ListFilterViewWrapper'; @@ -52,6 +52,12 @@ type MultiSelectProps = SearchFilterCommonProps>> & /** Whether to show the loading placeholder */ shouldShowLoadingPlaceholder?: boolean; + + /** Called when the scroll position gets near the end of the list */ + onEndReached?: () => void; + + /** Called when the debounced search term changes */ + onSearchChange?: (searchTerm: string) => void; }; function MultiSelect({ @@ -68,6 +74,8 @@ function MultiSelect({ autoFocus, footer, onChange, + onEndReached, + onSearchChange, }: MultiSelectProps) { const theme = useTheme(); const {translate} = useLocalize(); @@ -76,6 +84,11 @@ function MultiSelect({ const [selectedItems, setSelectedItems] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + // Notify parent when debounced search term changes (for server-side search) + useEffect(() => { + onSearchChange?.(debouncedSearchTerm); + }, [debouncedSearchTerm, onSearchChange]); + // Snapshot the values selected when the filter first opened so they can be floated to the top of a long list on // first render without repinning rows that are toggled afterwards. // moveInitialSelectionToTop gates on the *unfiltered* items length so the decision doesn't flip as the user types, @@ -84,9 +97,11 @@ function MultiSelect({ const orderedItems = moveInitialSelectionToTop(items, initialSelectedValues); const searchLower = debouncedSearchTerm.toLowerCase(); - const filteredItems = isSearchable - ? orderedItems.filter((item) => item.text.toLowerCase().includes(searchLower) || item.searchableText?.toLowerCase().includes(searchLower)) - : orderedItems; + // When onSearchChange is provided, the parent handles filtering (server-side search) + const filteredItems = + isSearchable && !onSearchChange + ? orderedItems.filter((item) => item.text.toLowerCase().includes(searchLower) || item.searchableText?.toLowerCase().includes(searchLower)) + : orderedItems; const listData: ListItem[] = filteredItems.map((item) => ({ text: item.text, alternateText: item.alternateText, @@ -151,6 +166,7 @@ function MultiSelect({ textInputOptions={textInputOptions} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} footerContent={footer} + onEndReached={onEndReached} /> )} diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index bb1ae7eca189..90a6e7a0e30b 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -1,19 +1,14 @@ import type {Filter, SearchFilterCommonProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import useSearchTagFilters from '@hooks/useSearchTagFilters'; -import {getCleanedTagName, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; -import {getAllPolicyValues, sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; +import {getCleanedTagName} from '@libs/PolicyUtils'; +import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyTagLists} from '@src/types/onyx'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxCollection} from 'react-native-onyx'; - -import React from 'react'; +import React, {useMemo} from 'react'; import MultiSelect from './MultiSelect'; @@ -23,17 +18,27 @@ type TagSelectorProps = SearchFilterCommonProps & { function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate, localeCompare} = useLocalize(); - const [allPolicyTagLists = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const {searchResults, isLoading, hasMore, loadMore, search} = useSearchTagFilters(); - const tagItems = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; - const uniqueTagNames = new Set( - getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_TAGS, allPolicyTagLists).flatMap((policyTags) => getTagNamesFromTagsLists(policyTags ?? {})), - ); - tagItems.push( - ...Array.from(uniqueTagNames) - .map((tagName) => ({text: getCleanedTagName(tagName), value: tagName})) - .toSorted((a, b) => sortOptionsWithEmptyValue(a.text.toString(), b.text.toString(), localeCompare)), - ); + const tagItems = useMemo(() => { + const items = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; + const uniqueTagNames = new Set(); + + // Extract unique tag names from all policies in the search results + for (const policyTags of Object.values(searchResults ?? {})) { + for (const tag of Object.values(policyTags ?? {})) { + uniqueTagNames.add(tag.tagName); + } + } + + items.push( + ...Array.from(uniqueTagNames) + .map((tagName) => ({text: getCleanedTagName(tagName), value: tagName})) + .toSorted((a, b) => sortOptionsWithEmptyValue(a.text.toString(), b.text.toString(), localeCompare)), + ); + + return items; + }, [searchResults, translate, localeCompare]); const selectedTagsItems = value.map((tag) => { if (tag === CONST.SEARCH.TAG_EMPTY_VALUE) { @@ -46,12 +51,15 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti = CONST.STANDARD_LIST_ITEM_LIMIT} + isSearchable autoFocus={autoFocus} selectionListTextInputStyle={selectionListTextInputStyle} selectionListStyle={selectionListStyle} footer={footer} onChange={(tags) => onChange(tags.map((tag) => tag.value))} + onEndReached={hasMore ? loadMore : undefined} + onSearchChange={search} + loading={isLoading && tagItems.length <= 1} /> ); } diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts new file mode 100644 index 000000000000..594411007651 --- /dev/null +++ b/src/hooks/useSearchTagFilters.ts @@ -0,0 +1,70 @@ +import {openSearchTagFiltersPage} from '@libs/actions/Search'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useCallback, useState} from 'react'; + +import useOnyx from './useOnyx'; + +type UseSearchTagFiltersResult = { + /** The paginated tag search results from Onyx */ + searchResults: OnyxEntry; + + /** Whether a request is currently in flight */ + isLoading: boolean; + + /** Whether there are more pages to load */ + hasMore: boolean; + + /** Load the next page of results */ + loadMore: () => void; + + /** Start a new search with the given query */ + search: (query: string) => void; +}; + +/** + * Hook for managing paginated tag filter search. + * Handles API calls, pagination state, and Onyx data subscription. + */ +function useSearchTagFilters(): UseSearchTagFiltersResult { + const [searchResults] = useOnyx(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS); + const [isLoading, setIsLoading] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [nextCursor, setNextCursor] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + + const loadMore = useCallback(() => { + if (isLoading || !hasMore) { + return; + } + setIsLoading(true); + openSearchTagFiltersPage({searchQuery, cursor: nextCursor, limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) + .then(({hasMore: newHasMore, nextCursor: newCursor}) => { + setHasMore(newHasMore); + setNextCursor(newCursor); + }) + .finally(() => setIsLoading(false)); + }, [isLoading, hasMore, searchQuery, nextCursor]); + + const search = useCallback((query: string) => { + setSearchQuery(query); + setNextCursor(''); + setHasMore(false); + setIsLoading(true); + openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) + .then(({hasMore: newHasMore, nextCursor: newCursor}) => { + setHasMore(newHasMore); + setNextCursor(newCursor); + }) + .finally(() => setIsLoading(false)); + }, []); + + return {searchResults, isLoading, hasMore, loadMore, search}; +} + +export default useSearchTagFilters; diff --git a/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts b/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts new file mode 100644 index 000000000000..385841139c46 --- /dev/null +++ b/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts @@ -0,0 +1,12 @@ +type OpenSearchTagFiltersPageParams = { + /** Search query to filter tags */ + searchQuery?: string; + + /** Cursor for pagination */ + cursor?: string; + + /** Maximum number of tags to return per page */ + limit?: number; +}; + +export default OpenSearchTagFiltersPageParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index fb845d542865..edffe34f2242 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -572,6 +572,7 @@ export type {default as AddAdminToDomainParams} from './AddAdminToDomainParams'; export type {default as UpdateSamlRequiredParams} from './UpdateSamlRequiredParams'; export type {default as AddMemberToDomainParams} from './AddMemberToDomainParams'; export type {default as OpenSearchPageParams} from './OpenSearchPageParams'; +export type {default as OpenSearchTagFiltersPageParams} from './OpenSearchTagFiltersPageParams'; export type {default as OpenBulkChangeApproverPageParams} from './OpenBulkChangeApproverPageParams'; export type {default as SetPolicyRequireCompanyCardsEnabledParams} from './SetPolicyRequireCompanyCardsEnabled'; export type {default as SetTechnicalContactEmailParams} from './SetTechnicalContactEmailParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f5192b6014b2..69f1f2960cc0 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1665,6 +1665,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { LINK_CARD_FEED_TO_POLICY: 'LinkCardFeedToPolicy', REVEAL_CARD_PIN: 'RevealCardPIN', CHANGE_CARD_PIN: 'ChangeCardPIN', + OPEN_SEARCH_TAG_FILTERS_PAGE: 'OpenSearchTagFiltersPage', } as const; type SideEffectRequestCommand = ValueOf; @@ -1714,6 +1715,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.LINK_CARD_FEED_TO_POLICY]: Parameters.LinkCardToPolicyParams; [SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_CARD_PIN]: Parameters.RevealCardPINParams; [SIDE_EFFECT_REQUEST_COMMANDS.CHANGE_CARD_PIN]: Parameters.ChangeCardPINParams; + [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE]: Parameters.OpenSearchTagFiltersPageParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 0b107bcbb6c2..29c6b58011cf 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -16,12 +16,13 @@ import type { ExportSearchWithTemplateParams, OpenBulkChangeApproverPageParams, OpenSearchPageParams, + OpenSearchTagFiltersPageParams, QueueExportSearchItemsToCSVParams, QueueExportSearchWithTemplateParams, ReportExportParams, SubmitReportParams, } from '@libs/API/parameters'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getCommandURL} from '@libs/ApiUtils'; import deferModalPresentationAfterPopoverDismiss from '@libs/deferModalPresentationAfterPopoverDismiss'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; @@ -1044,6 +1045,17 @@ function openSearchCategoryFiltersPage() { read(READ_COMMANDS.OPEN_SEARCH_CATEGORY_FILTERS_PAGE, null, {optimisticData, successData, finallyData}); } +/** + * Fetches a page of tag filter search results from the server. + * Returns pagination metadata (hasMore, nextCursor) for infinite scroll. + */ +function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams): Promise<{hasMore: boolean; nextCursor: string}> { + return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, params).then((response) => ({ + hasMore: !!response?.hasMore, + nextCursor: response?.nextCursor ?? '', + })); +} + function openBulkChangeApproverPage(reportIDList: OpenBulkChangeApproverPageParams['reportIDList']) { const optimisticData: Array> = [ { @@ -2391,6 +2403,7 @@ export { handlePreventSearchAPI, openSearchCardFiltersPage, openSearchCategoryFiltersPage, + openSearchTagFiltersPage, getPolicyFromSearchSnapshot, getReportFromSearchSnapshot, resolveSearchPayPaymentMethod, diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts index dcbeebeaa686..832747171fd0 100644 --- a/src/types/onyx/PolicyTag.ts +++ b/src/types/onyx/PolicyTag.ts @@ -64,4 +64,16 @@ type PolicyTagLists = Record; -export type {PolicyTag, PolicyTags, PolicyTagLists, ParticipantsPolicyTags}; +/** Simplified tag entry for search filter results */ +type SearchPolicyTag = { + /** Name of the tag list */ + tagListName: string; + + /** Name of the tag */ + tagName: string; +}; + +/** Record of search policy tags, indexed by ":" */ +type SearchPolicyTags = Record; + +export type {PolicyTag, PolicyTags, PolicyTagLists, ParticipantsPolicyTags, SearchPolicyTag, SearchPolicyTags}; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index ab831c9d88a5..2f136b772b33 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -129,6 +129,12 @@ type Response = { /** Cache key returned from GetExpensifyCardStatementPDF */ statementKey?: string; + + /** Whether there are more pages of tag filter results */ + hasMore?: boolean; + + /** Cursor for the next page of tag filter results */ + nextCursor?: string; }; export default Response; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 7b28bbb562d7..d6a35023febf 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -135,7 +135,7 @@ import type {PolicyEmployeeList} from './PolicyEmployee'; import type PolicyEmployee from './PolicyEmployee'; import type PolicyJoinMember from './PolicyJoinMember'; import type PolicyOwnershipChangeChecks from './PolicyOwnershipChangeChecks'; -import type {ParticipantsPolicyTags, PolicyTag, PolicyTagLists, PolicyTags} from './PolicyTag'; +import type {ParticipantsPolicyTags, PolicyTag, PolicyTagLists, PolicyTags, SearchPolicyTags} from './PolicyTag'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; import type PrivatePromoDiscount from './PrivatePromoDiscount'; import type PrivateSubscription from './PrivateSubscription'; @@ -311,6 +311,7 @@ export type { PolicyTags, PolicyTagLists, ParticipantsPolicyTags, + SearchPolicyTags, PrivatePersonalDetails, QuickAction, ShareBankAccount, From a847be22576b597dce79c34c4d2644894a770388 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:12:43 +0530 Subject: [PATCH 043/485] Remove client-side sorting to fix infinite scroll UX --- .../Search/FilterComponents/MultiSelect.tsx | 17 +++++++++++++- .../Search/FilterComponents/TagSelector.tsx | 22 +++++++++---------- src/hooks/useSearchTagFilters.ts | 6 ++--- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index bb5a49b264f2..6d8fc43bcde1 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -58,6 +58,9 @@ type MultiSelectProps = SearchFilterCommonProps>> & /** Called when the debounced search term changes */ onSearchChange?: (searchTerm: string) => void; + + /** Whether more items are being loaded (shows spinner at bottom of list) */ + isLoadingMore?: boolean; }; function MultiSelect({ @@ -76,6 +79,7 @@ function MultiSelect({ onChange, onEndReached, onSearchChange, + isLoadingMore, }: MultiSelectProps) { const theme = useTheme(); const {translate} = useLocalize(); @@ -141,6 +145,17 @@ function MultiSelect({ disableAutoFocus: !autoFocus, }; + const footerContent = isLoadingMore ? ( + + + + ) : ( + footer + ); + return ( ({ onSelectRow={updateSelectedItems} textInputOptions={textInputOptions} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} - footerContent={footer} + footerContent={footerContent} onEndReached={onEndReached} /> )} diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 90a6e7a0e30b..9de4d4c7319d 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -4,7 +4,6 @@ import useLocalize from '@hooks/useLocalize'; import useSearchTagFilters from '@hooks/useSearchTagFilters'; import {getCleanedTagName} from '@libs/PolicyUtils'; -import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; @@ -17,28 +16,26 @@ type TagSelectorProps = SearchFilterCommonProps & { }; function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { - const {translate, localeCompare} = useLocalize(); + const {translate} = useLocalize(); const {searchResults, isLoading, hasMore, loadMore, search} = useSearchTagFilters(); const tagItems = useMemo(() => { const items = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; - const uniqueTagNames = new Set(); + const seenTagNames = new Set(); - // Extract unique tag names from all policies in the search results + // Preserve backend order - new items append at end for infinite scroll for (const policyTags of Object.values(searchResults ?? {})) { for (const tag of Object.values(policyTags ?? {})) { - uniqueTagNames.add(tag.tagName); + if (seenTagNames.has(tag.tagName)) { + continue; + } + seenTagNames.add(tag.tagName); + items.push({text: getCleanedTagName(tag.tagName), value: tag.tagName}); } } - items.push( - ...Array.from(uniqueTagNames) - .map((tagName) => ({text: getCleanedTagName(tagName), value: tagName})) - .toSorted((a, b) => sortOptionsWithEmptyValue(a.text.toString(), b.text.toString(), localeCompare)), - ); - return items; - }, [searchResults, translate, localeCompare]); + }, [searchResults, translate]); const selectedTagsItems = value.map((tag) => { if (tag === CONST.SEARCH.TAG_EMPTY_VALUE) { @@ -60,6 +57,7 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti onEndReached={hasMore ? loadMore : undefined} onSearchChange={search} loading={isLoading && tagItems.length <= 1} + isLoadingMore={isLoading && tagItems.length > 1} /> ); } diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 594411007651..6828893acb89 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -4,15 +4,15 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import {useCallback, useState} from 'react'; import useOnyx from './useOnyx'; type UseSearchTagFiltersResult = { - /** The paginated tag search results from Onyx */ - searchResults: OnyxEntry; + /** The paginated tag search results from Onyx, keyed by full Onyx key (searchPolicyTags_) */ + searchResults: OnyxCollection; /** Whether a request is currently in flight */ isLoading: boolean; From b6af9acf80b8e2548f2891b8f7abe9a0583ca363 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:37:31 +0530 Subject: [PATCH 044/485] Show cached tags immediately, fetch fresh data in background --- src/hooks/useSearchTagFilters.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 6828893acb89..e2176e102224 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -6,7 +6,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import useOnyx from './useOnyx'; @@ -37,6 +37,15 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { const [hasMore, setHasMore] = useState(false); const [nextCursor, setNextCursor] = useState(''); const [searchQuery, setSearchQuery] = useState(''); + const hasCachedData = useRef(false); + + // Track if we have cached data to avoid showing loading state on remount + useEffect(() => { + if (!searchResults || Object.keys(searchResults).length === 0) { + return; + } + hasCachedData.current = true; + }, [searchResults]); const loadMore = useCallback(() => { if (isLoading || !hasMore) { @@ -55,7 +64,10 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { setSearchQuery(query); setNextCursor(''); setHasMore(false); - setIsLoading(true); + // Only show loading if no cached data - otherwise fetch silently in background + if (!hasCachedData.current) { + setIsLoading(true); + } openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { setHasMore(newHasMore); From 8c29130cb1bd65590acd6086bb358603741a928c Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:41:54 +0530 Subject: [PATCH 045/485] Fix cached data check to avoid loading spinner on remount --- src/hooks/useSearchTagFilters.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index e2176e102224..12af8f565482 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -37,14 +37,11 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { const [hasMore, setHasMore] = useState(false); const [nextCursor, setNextCursor] = useState(''); const [searchQuery, setSearchQuery] = useState(''); - const hasCachedData = useRef(false); - // Track if we have cached data to avoid showing loading state on remount + // Keep ref updated with latest searchResults for use in stable callbacks + const searchResultsRef = useRef(searchResults); useEffect(() => { - if (!searchResults || Object.keys(searchResults).length === 0) { - return; - } - hasCachedData.current = true; + searchResultsRef.current = searchResults; }, [searchResults]); const loadMore = useCallback(() => { @@ -65,7 +62,8 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { setNextCursor(''); setHasMore(false); // Only show loading if no cached data - otherwise fetch silently in background - if (!hasCachedData.current) { + const hasCachedData = searchResultsRef.current && Object.keys(searchResultsRef.current).length > 0; + if (!hasCachedData) { setIsLoading(true); } openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) From 16b5724f196b8757dbd4eab79a529b91a31756bf Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:35:27 +0530 Subject: [PATCH 046/485] Persist tag filter pagination state in Onyx --- src/ONYXKEYS.ts | 4 ++ src/hooks/useSearchTagFilters.ts | 50 +++++++++++-------- src/libs/actions/Search.ts | 13 +++++ .../onyx/SearchTagFiltersPaginationState.ts | 18 +++++++ src/types/onyx/index.ts | 2 + 5 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 src/types/onyx/SearchTagFiltersPaginationState.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 66c720676a93..531d1fd5a8de 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -49,6 +49,9 @@ const ONYXKEYS = { /** Boolean flag set whenever we are searching for reports in the server */ RAM_ONLY_IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', + /** Pagination state for tag filter search (hasMore, nextCursor). RAM-only so it resets on app restart. */ + RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION: 'searchTagFiltersPagination', + /** Boolean flag indicating a SignInWithShortLivedAuthToken request is in flight. RAM-only so an interrupted request never persists a stuck `true` to IndexedDB and blocks future reauth attempts. */ RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN: 'isAuthenticatingWithShortLivedToken', @@ -1749,6 +1752,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: boolean; + [ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION]: OnyxTypes.SearchTagFiltersPaginationState; [ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.REPORT_LAST_VISIT_TIMES]: OnyxTypes.ReportLastVisitTimes; diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 12af8f565482..60b888882432 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -1,4 +1,4 @@ -import {openSearchTagFiltersPage} from '@libs/actions/Search'; +import {openSearchTagFiltersPage, setSearchTagFiltersPagination} from '@libs/actions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -30,46 +30,56 @@ type UseSearchTagFiltersResult = { /** * Hook for managing paginated tag filter search. * Handles API calls, pagination state, and Onyx data subscription. + * Pagination state is persisted in Onyx (RAM-only) to survive component remounts. */ function useSearchTagFilters(): UseSearchTagFiltersResult { const [searchResults] = useOnyx(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS); + const [paginationState] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION); const [isLoading, setIsLoading] = useState(false); - const [hasMore, setHasMore] = useState(false); - const [nextCursor, setNextCursor] = useState(''); - const [searchQuery, setSearchQuery] = useState(''); - // Keep ref updated with latest searchResults for use in stable callbacks - const searchResultsRef = useRef(searchResults); + // Derive pagination state from Onyx + const hasMore = paginationState?.hasMore ?? false; + const nextCursor = paginationState?.nextCursor ?? ''; + const searchQuery = paginationState?.searchQuery ?? ''; + + // Track if we have cached data to avoid showing loading state on remount + const hasCachedData = searchResults && Object.keys(searchResults).length > 0; + + // Keep ref updated with latest values for use in stable callbacks + const stateRef = useRef({hasMore, nextCursor, searchQuery, hasCachedData, isLoading}); useEffect(() => { - searchResultsRef.current = searchResults; - }, [searchResults]); + stateRef.current = {hasMore, nextCursor, searchQuery, hasCachedData, isLoading}; + }, [hasMore, nextCursor, searchQuery, hasCachedData, isLoading]); const loadMore = useCallback(() => { - if (isLoading || !hasMore) { + const {hasMore: currentHasMore, nextCursor: currentCursor, searchQuery: currentQuery, isLoading: currentIsLoading} = stateRef.current; + if (currentIsLoading || !currentHasMore) { return; } setIsLoading(true); - openSearchTagFiltersPage({searchQuery, cursor: nextCursor, limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) + openSearchTagFiltersPage({searchQuery: currentQuery, cursor: currentCursor, limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { - setHasMore(newHasMore); - setNextCursor(newCursor); + setSearchTagFiltersPagination(newHasMore, newCursor, currentQuery); }) .finally(() => setIsLoading(false)); - }, [isLoading, hasMore, searchQuery, nextCursor]); + }, []); const search = useCallback((query: string) => { - setSearchQuery(query); - setNextCursor(''); - setHasMore(false); + const {hasCachedData: currentHasCachedData, searchQuery: currentQuery} = stateRef.current; + + // Skip fetch if same query and we already have data (e.g., on remount with cached data) + if (query === currentQuery && currentHasCachedData) { + return; + } + // Only show loading if no cached data - otherwise fetch silently in background - const hasCachedData = searchResultsRef.current && Object.keys(searchResultsRef.current).length > 0; - if (!hasCachedData) { + if (!currentHasCachedData) { setIsLoading(true); } + openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { - setHasMore(newHasMore); - setNextCursor(newCursor); + setSearchTagFiltersPagination(newHasMore, newCursor, query); }) .finally(() => setIsLoading(false)); }, []); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 29c6b58011cf..de4bb2f6d76d 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1056,6 +1056,18 @@ function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams): Promi })); } +/** + * Updates the pagination state for tag filter search. + * Stored in RAM-only Onyx key so it survives component remounts but resets on app restart. + */ +function setSearchTagFiltersPagination(hasMore: boolean, nextCursor: string, searchQuery: string) { + Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, { + hasMore, + nextCursor, + searchQuery, + }); +} + function openBulkChangeApproverPage(reportIDList: OpenBulkChangeApproverPageParams['reportIDList']) { const optimisticData: Array> = [ { @@ -2404,6 +2416,7 @@ export { openSearchCardFiltersPage, openSearchCategoryFiltersPage, openSearchTagFiltersPage, + setSearchTagFiltersPagination, getPolicyFromSearchSnapshot, getReportFromSearchSnapshot, resolveSearchPayPaymentMethod, diff --git a/src/types/onyx/SearchTagFiltersPaginationState.ts b/src/types/onyx/SearchTagFiltersPaginationState.ts new file mode 100644 index 000000000000..9e5c178086ec --- /dev/null +++ b/src/types/onyx/SearchTagFiltersPaginationState.ts @@ -0,0 +1,18 @@ +/** + * Pagination state for tag filter search results. + * + * Stored separately from the tag data so pagination survives component remounts + * without persisting to disk (RAM-only). + */ +type SearchTagFiltersPaginationState = { + /** Whether there are more pages to load */ + hasMore: boolean; + + /** Cursor for fetching the next page */ + nextCursor: string; + + /** The search query that produced this pagination state */ + searchQuery: string; +}; + +export default SearchTagFiltersPaginationState; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index d6a35023febf..52cb014a4ece 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -175,6 +175,7 @@ import type SearchContext from './SearchContext'; import type SearchFooterConversion from './SearchFooterConversion'; import type SearchResults from './SearchResults'; import type SearchSidebar from './SearchSidebar'; +import type SearchTagFiltersPaginationState from './SearchTagFiltersPaginationState'; import type SecurityGroup from './SecurityGroup'; import type {DomainSecurityGroupMembership} from './SecurityGroup'; import type SelectedTabRequest from './SelectedTabRequest'; @@ -382,6 +383,7 @@ export type { NewGroupChatDraft, PolicyJoinMember, SearchResults, + SearchTagFiltersPaginationState, ReviewDuplicates, PrivateSubscription, PrivatePromoDiscount, From bb0eef2377fa50a2d49fe829795513ff0479a668 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:51:37 +0530 Subject: [PATCH 047/485] Show Tag filter when tags are enabled for any policy --- src/hooks/useAdvancedSearchFilters.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/hooks/useAdvancedSearchFilters.ts b/src/hooks/useAdvancedSearchFilters.ts index d681f4556476..381676c4b14d 100644 --- a/src/hooks/useAdvancedSearchFilters.ts +++ b/src/hooks/useAdvancedSearchFilters.ts @@ -2,11 +2,10 @@ import type {Filter} from '@components/Search/types'; import {isFilterableBankAccount} from '@libs/BankAccountUtils'; import {isPolicyFeatureEnabled} from '@libs/PolicyUtils'; -import {getAllPolicyValues} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardList, Policy, PolicyTagLists} from '@src/types/onyx'; +import type {CardList, Policy} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; @@ -288,14 +287,6 @@ function advancedSearchPoliciesSelector(policies: OnyxCollection): OnyxC * Checks whether a single policy's tag lists contain at least one tag. * Short-circuits on the first tag found. */ -const policyTagListHasTags = (policyTagList: PolicyTagLists | undefined) => Object.values(policyTagList ?? {}).some((tagList) => Object.keys(tagList.tags ?? {}).length > 0); - -/** - * Selector that checks if any tags exist across all policy tag lists. - * Returns a boolean with early exit on first tag found. - */ -const hasTagsSelector = (allPolicyTagLists: OnyxCollection) => Object.values(allPolicyTagLists ?? {}).some(policyTagListHasTags); - function useAdvancedSearchFiltersWorkspaces(policies: OnyxCollection, searchTerm?: string) { const {localeCompare} = useLocalize(); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); @@ -317,19 +308,16 @@ function shouldDisplayCardFilterSelector(cardList: OnyxEntry) { return shouldDisplayFilter(Object.keys(filterCardsHiddenFromSearch(cardList)).length, true); } -function useAdvancedSearchFilters(type: SearchDataTypes | undefined, policyID: Filter | undefined) { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function useAdvancedSearchFilters(type: SearchDataTypes | undefined, _policyID: Filter | undefined) { const [shouldDisplayCardFilter] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: shouldDisplayCardFilterSelector}); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: advancedSearchPoliciesSelector}); const [policyDerived] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policyDerivedSelector}); - const [allPolicyTagLists = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const selectedPolicyTagLists = policyID?.value?.length ? getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_TAGS, allPolicyTagLists) : []; - const [hasTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: hasTagsSelector}); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const {workspaces} = useAdvancedSearchFiltersWorkspaces(policies); - const hasSelectedPolicyTags = selectedPolicyTagLists.some(policyTagListHasTags); - const shouldDisplayTagFilter = shouldDisplayFilter(hasTags ? 1 : 0, policyDerived?.areTagsEnabled ?? false, hasSelectedPolicyTags); + const shouldDisplayTagFilter = policyDerived?.areTagsEnabled ?? false; // Count business accounts that aren't partially set up, mirroring BankAccountSelector so the row never shows above an empty picker. const hasFilterableBankAccount = Object.values(bankAccountList ?? {}).some(isFilterableBankAccount); const shouldDisplayBankAccountFilter = shouldDisplayFilter(hasFilterableBankAccount ? 1 : 0, true); From e3419f66b6259cd1433b3ed3f9adf2e70a636bf7 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:18:56 +0530 Subject: [PATCH 048/485] Update tag filter visibility tests --- .../hooks/useAdvancedSearchFilters.test.ts | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/tests/unit/hooks/useAdvancedSearchFilters.test.ts b/tests/unit/hooks/useAdvancedSearchFilters.test.ts index 5b301f6ab233..2a5bc234191d 100644 --- a/tests/unit/hooks/useAdvancedSearchFilters.test.ts +++ b/tests/unit/hooks/useAdvancedSearchFilters.test.ts @@ -140,48 +140,27 @@ describe('useAdvancedSearchFilters', () => { }); }); - it('hides tag filter when tags are enabled but no tags exist', async () => { + it('shows tag filter when tags are enabled but no tags exist', async () => { const policy = buildPolicy(1, {areTagsEnabled: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const emptyTagList: PolicyTagLists = { - Department: { - name: 'Department', - orderWeight: 0, - required: false, - tags: {}, - }, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}1`, emptyTagList); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); - // Tag filter is hidden because the tag list contains no actual tags await waitFor(() => { const allKeys = result.current.flat(); - expect(allKeys).not.toContain(CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG); + expect(allKeys).toContain(CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG); }); }); - it('hides tag filter when a selected policy has tags enabled but no tags exist', async () => { + it('shows tag filter when a selected policy has tags enabled but no tags exist', async () => { const policy = buildPolicy(1, {areTagsEnabled: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const emptyTagList: PolicyTagLists = { - Department: { - name: 'Department', - orderWeight: 0, - required: false, - tags: {}, - }, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}1`, emptyTagList); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, {value: ['1'], isNegated: false}), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); - expect(allKeys).not.toContain(CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG); + expect(allKeys).toContain(CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG); }); }); From d0c95cadee1eb983d2473b7d4197896d402c132e Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:28:22 +0530 Subject: [PATCH 049/485] Reset pagination state immediately when search query changes --- src/hooks/useSearchTagFilters.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 60b888882432..bec596899e49 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -72,6 +72,9 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { return; } + // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor + setSearchTagFiltersPagination(false, '', query); + // Only show loading if no cached data - otherwise fetch silently in background if (!currentHasCachedData) { setIsLoading(true); From 0bb24a1a1d85746e3e4d2d10edf7bd825c0a30cb Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:35:55 +0530 Subject: [PATCH 050/485] fix: prevent focus loss when searching tags --- src/components/Search/FilterComponents/TagSelector.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 9de4d4c7319d..3c41a0561b65 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -17,7 +17,7 @@ type TagSelectorProps = SearchFilterCommonProps & { function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); - const {searchResults, isLoading, hasMore, loadMore, search} = useSearchTagFilters(); + const {searchResults, isLoading, hasMore, loadMore, search, hasCachedData} = useSearchTagFilters(); const tagItems = useMemo(() => { const items = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; @@ -56,7 +56,7 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti onChange={(tags) => onChange(tags.map((tag) => tag.value))} onEndReached={hasMore ? loadMore : undefined} onSearchChange={search} - loading={isLoading && tagItems.length <= 1} + loading={isLoading && tagItems.length <= 1 && !hasCachedData} isLoadingMore={isLoading && tagItems.length > 1} /> ); From 7c90110e36a18705f526aaceff7260de64225708 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:14:57 +0530 Subject: [PATCH 051/485] Return hasCachedData from hook and register RAM-only pagination key --- src/components/Search/FilterComponents/TagSelector.tsx | 2 +- src/hooks/useSearchTagFilters.ts | 7 +++++-- src/setup/index.ts | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 3c41a0561b65..649bc9a1bbca 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -15,7 +15,7 @@ type TagSelectorProps = SearchFilterCommonProps & { policyID: Filter | undefined; }; -function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { +function TagSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); const {searchResults, isLoading, hasMore, loadMore, search, hasCachedData} = useSearchTagFilters(); diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index bec596899e49..b171fee31a23 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -25,6 +25,9 @@ type UseSearchTagFiltersResult = { /** Start a new search with the given query */ search: (query: string) => void; + + /** Whether there are cached results from a previous fetch */ + hasCachedData: boolean; }; /** @@ -43,7 +46,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { const searchQuery = paginationState?.searchQuery ?? ''; // Track if we have cached data to avoid showing loading state on remount - const hasCachedData = searchResults && Object.keys(searchResults).length > 0; + const hasCachedData = !!searchResults && Object.keys(searchResults).length > 0; // Keep ref updated with latest values for use in stable callbacks const stateRef = useRef({hasMore, nextCursor, searchQuery, hasCachedData, isLoading}); @@ -87,7 +90,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { .finally(() => setIsLoading(false)); }, []); - return {searchResults, isLoading, hasMore, loadMore, search}; + return {searchResults, isLoading, hasMore, loadMore, search, hasCachedData}; } export default useSearchTagFilters; diff --git a/src/setup/index.ts b/src/setup/index.ts index 27bf046f527e..47f3696d5aab 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -76,6 +76,7 @@ export default function () { ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, + ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE, From a8d1d4d828af5a6abe2e507e7f7bd5c188462e7b Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:41:16 +0530 Subject: [PATCH 052/485] Bucket new Onyx keys for state export and drop unused type export --- src/libs/ExportOnyxState/common.ts | 2 ++ src/types/onyx/PolicyTag.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 3d0cf2d30102..cdf102bf812d 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -391,6 +391,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING, ONYXKEYS.COLLECTION.RULE, ONYXKEYS.COLLECTION.SAML_METADATA, + ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, ONYXKEYS.COLLECTION.SECURITY_GROUP, ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT, ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, @@ -476,6 +477,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, + ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, ONYXKEYS.RECENTLY_USED_CURRENCIES, ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts index 832747171fd0..21088d43c032 100644 --- a/src/types/onyx/PolicyTag.ts +++ b/src/types/onyx/PolicyTag.ts @@ -76,4 +76,4 @@ type SearchPolicyTag = { /** Record of search policy tags, indexed by ":" */ type SearchPolicyTags = Record; -export type {PolicyTag, PolicyTags, PolicyTagLists, ParticipantsPolicyTags, SearchPolicyTag, SearchPolicyTags}; +export type {PolicyTag, PolicyTags, PolicyTagLists, ParticipantsPolicyTags, SearchPolicyTags}; From 2701438b95a10ec33f82ded41bfc4e2b9f51c658 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:57:43 +0530 Subject: [PATCH 053/485] Remove unused policyID prop from TagSelector --- src/components/Search/FilterComponents/TagSelector.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 649bc9a1bbca..7e8d04691372 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -1,4 +1,4 @@ -import type {Filter, SearchFilterCommonProps} from '@components/Search/types'; +import type {SearchFilterCommonProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useSearchTagFilters from '@hooks/useSearchTagFilters'; @@ -11,9 +11,7 @@ import React, {useMemo} from 'react'; import MultiSelect from './MultiSelect'; -type TagSelectorProps = SearchFilterCommonProps & { - policyID: Filter | undefined; -}; +type TagSelectorProps = SearchFilterCommonProps; function TagSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); From 57d968c5cb910554de9a07f99136a4e52c29eab9 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:40:20 +0530 Subject: [PATCH 054/485] Always fetch tag filters on open instead of skipping when cache exists --- src/hooks/useSearchTagFilters.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index b171fee31a23..ec03aadc0760 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -68,12 +68,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { }, []); const search = useCallback((query: string) => { - const {hasCachedData: currentHasCachedData, searchQuery: currentQuery} = stateRef.current; - - // Skip fetch if same query and we already have data (e.g., on remount with cached data) - if (query === currentQuery && currentHasCachedData) { - return; - } + const {hasCachedData: currentHasCachedData} = stateRef.current; // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor setSearchTagFiltersPagination(false, '', query); From 92a8e50a21fb904c414d93237852d938acaab047 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:08:24 +0530 Subject: [PATCH 055/485] Keep paginated tag filter selections when validating the filter form --- src/hooks/useFilterFormValues.tsx | 2 ++ src/libs/SearchQueryUtils.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/hooks/useFilterFormValues.tsx b/src/hooks/useFilterFormValues.tsx index ffbecb9e2d51..82629739650a 100644 --- a/src/hooks/useFilterFormValues.tsx +++ b/src/hooks/useFilterFormValues.tsx @@ -85,6 +85,7 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { // lookups (`in:` filter), so projecting via a selector would just waste an O(n) pass over every report. const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: policyTagsSelector}); + const [searchPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {selector: policyCategoriesSelector}); const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); @@ -107,6 +108,7 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { exportedToFilterOptions, currentUserPersonalDetails.accountID, bankAccountList, + searchPolicyTags, ) : getEmptyObject>(); diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 1fb7a3714c07..0a6dbb198da6 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1335,6 +1335,7 @@ function buildFilterFormValuesFromQuery( exportedToFilterOptions?: string[], currentUserAccountID?: number, bankAccountList?: OnyxTypes.BankAccountList, + searchPolicyTags?: OnyxCollection, ) { const filters = queryJSON.flatFilters; const filtersForm = {} as Partial; @@ -1470,6 +1471,12 @@ function buildFilterFormValuesFromQuery( } } } + // Tags fetched through the paginated tag filter endpoint may not exist in the full policy tags cache + for (const searchTags of Object.values(searchPolicyTags ?? {})) { + for (const tag of Object.values(searchTags ?? {})) { + uniqueTags.add(tag.tagName); + } + } uniqueTags.add(CONST.SEARCH.TAG_EMPTY_VALUE); filtersForm[addNegation(filterKey, isNegated)] = filterValues.filter((name) => uniqueTags.has(name)); } From f1962c6d2f950e7b842f2a597a0b728db7e6ecde Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:09:53 +0530 Subject: [PATCH 056/485] Log tag filter request failures instead of leaving unhandled rejections --- src/hooks/useSearchTagFilters.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index ec03aadc0760..694b386b1bd8 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -1,4 +1,5 @@ import {openSearchTagFiltersPage, setSearchTagFiltersPagination} from '@libs/actions/Search'; +import Log from '@libs/Log'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -64,6 +65,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { .then(({hasMore: newHasMore, nextCursor: newCursor}) => { setSearchTagFiltersPagination(newHasMore, newCursor, currentQuery); }) + .catch((error: Error) => Log.warn('Failed to load the next tag filters page', {error})) .finally(() => setIsLoading(false)); }, []); @@ -82,6 +84,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { .then(({hasMore: newHasMore, nextCursor: newCursor}) => { setSearchTagFiltersPagination(newHasMore, newCursor, query); }) + .catch((error: Error) => Log.warn('Failed to fetch tag filters', {error})) .finally(() => setIsLoading(false)); }, []); From 524319578fa63bcb945d04fd07e3f37306ee8941 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:14:40 +0530 Subject: [PATCH 057/485] Keep the confirm footer visible while loading more tag filter pages --- .../Search/FilterComponents/MultiSelect.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 6d8fc43bcde1..8bda8f8dcdb9 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -145,15 +145,18 @@ function MultiSelect({ disableAutoFocus: !autoFocus, }; - const footerContent = isLoadingMore ? ( - - - - ) : ( - footer + const footerContent = ( + <> + {!!isLoadingMore && ( + + + + )} + {footer} + ); return ( From af7c86e0c07db4606614c0cb8eacd7b63009152e Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Sun, 30 Aug 2026 15:45:22 +0000 Subject: [PATCH 058/485] Fade content in and out on Spend page query changes Changing the search type or a filter swapped the results area with a hard cut. Each query hash now owns an absolutely filled layer: the outgoing one fades out, the incoming one waits for it to finish before fading in. - Skeleton and Search content fade at their swap boundaries so a late response crossfades into the list instead of cutting. - LayoutAnimationConfig skipEntering keeps the delay off a cold open. - FADE_DURATION 200 -> 150 to keep the sequential swap at 300ms. --- src/CONST/index.ts | 2 +- .../Search/SearchLoadingSkeleton.tsx | 8 ++-- .../SearchWithNavigationDeferredMount.tsx | 19 +++++--- src/components/Search/index.tsx | 26 ++++++++--- src/pages/Search/SearchPageNarrow/index.tsx | 44 ++++++++++++------- src/pages/Search/SearchPageWide.tsx | 43 +++++++++++------- 6 files changed, 96 insertions(+), 46 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1fa5f67728bc..629fb41f099d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7759,7 +7759,7 @@ const CONST = { SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', ANIMATION: { - FADE_DURATION: 200, + FADE_DURATION: 150, }, TODO_BADGE_MAX_COUNT: 50, TOP_SEARCH_LIMIT: 10, diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index 77908ab0607c..b27631a860bd 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -10,7 +10,8 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import {StyleSheet} from 'react-native'; +import Animated, {FadeOut} from 'react-native-reanimated'; type SearchLoadingSkeletonProps = { containerStyle?: StyleProp; @@ -20,10 +21,11 @@ function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { const styles = useThemeStyles(); return ( + // The skeleton is absolutely filled so that its exit fade overlays the incoming results instead of + // sharing the parent's column layout with them, which would halve both heights for the fade duration. { endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.COLD); diff --git a/src/components/Search/SearchWithNavigationDeferredMount.tsx b/src/components/Search/SearchWithNavigationDeferredMount.tsx index b265db02c924..76eb379e3863 100644 --- a/src/components/Search/SearchWithNavigationDeferredMount.tsx +++ b/src/components/Search/SearchWithNavigationDeferredMount.tsx @@ -13,6 +13,8 @@ import CONST from '@src/CONST'; import type {ComponentProps} from 'react'; import React from 'react'; +import {StyleSheet} from 'react-native'; +import Animated, {FadeOut} from 'react-native-reanimated'; import Search from './index'; @@ -39,11 +41,18 @@ function SearchWithNavigationDeferredMount(props: ComponentProps) + // Absolutely filled so the exit fade overlays the incoming Search content rather than sharing + // the parent's column layout with it, which would halve both heights for the fade duration. + + + } > diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 13e67b10632b..1e0202437b55 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -88,7 +88,7 @@ import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@re import * as Sentry from '@sentry/react-native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {FadeIn, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -1064,7 +1064,10 @@ function Search({ ) { cancelNavigationSpans(); return ( - + - + ); } @@ -1111,7 +1114,10 @@ function Search({ onLayout={onLayoutChart} scrollEventThrottle={CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE} > - + - + ); @@ -1232,7 +1238,15 @@ function Search({ isExpenseReportType={isExpenseReportType} isSearchResultsEmpty={isSearchResultsEmpty} > - {searchListContent} + + {/* The inner view owns the mount fade so it composes with the outer column-change opacity instead of fighting it. */} + + {searchListContent} + + ); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 07ed89225990..76c7068c98e1 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -43,7 +43,7 @@ import type {SearchResults} from '@src/types/onyx'; import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useRef, useState, useTransition} from 'react'; import {StyleSheet, View} from 'react-native'; -import Animated, {clamp, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {clamp, FadeIn, FadeOut, LayoutAnimationConfig, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import {SearchActionsBarSwitch, SearchFiltersBarSwitch, SearchPageInputSwitch, SearchTypeMenuSwitch} from './Switches'; @@ -334,22 +334,34 @@ function SearchPageNarrow({ )} {!useStaticRendering && ( <> - {shouldShowLoadingSkeleton ? ( - - ) : ( - + {/* Changing the search type or a filter changes the hash, which remounts this layer: the outgoing one + fades out and the incoming one waits for it to finish before fading in. Both layers are absolutely + filled so the outgoing fade overlays the incoming layer instead of sharing the column layout. */} + - )} + entering={FadeIn.duration(CONST.SEARCH.ANIMATION.FADE_DURATION).delay(CONST.SEARCH.ANIMATION.FADE_DURATION)} + exiting={FadeOut.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)} + style={StyleSheet.absoluteFill} + > + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} + + {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( - {shouldShowLoadingSkeleton ? ( - - ) : ( - + {/* Changing the search type or a filter changes the hash, which remounts this layer: the outgoing one + fades out and the incoming one waits for it to finish before fading in. Both layers are absolutely + filled so the outgoing fade overlays the incoming layer instead of sharing the column layout. */} + - )} + entering={FadeIn.duration(CONST.SEARCH.ANIMATION.FADE_DURATION).delay(CONST.SEARCH.ANIMATION.FADE_DURATION)} + exiting={FadeOut.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)} + style={StyleSheet.absoluteFill} + > + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} + + {!!searchOverlayContent && {searchOverlayContent}} From b44ed4cd56cff8e9dd02beb98ac6fa181d6438d6 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:17:08 +0530 Subject: [PATCH 059/485] Trigger server-side tag search from the text change handler instead of an effect --- .../Search/FilterComponents/MultiSelect.tsx | 19 +++++++++++++------ src/hooks/useSearchTagFilters.ts | 5 +++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 8bda8f8dcdb9..07f6db40e392 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -5,6 +5,7 @@ import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectL import type {ListItem} from '@components/SelectionList/ListItem/types'; import type {TextInputOptions} from '@components/SelectionList/types'; +import useDebounce from '@hooks/useDebounce'; import useDebouncedState from '@hooks/useDebouncedState'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; @@ -18,11 +19,14 @@ import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {ReactNode} from 'react'; -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import ListFilterView from './ListFilterViewWrapper'; +// Stable fallback so the debounced search callback keeps its identity when no search handler is provided +function noop() {} + type MultiSelectItem = { text: string; value: T; @@ -88,10 +92,13 @@ function MultiSelect({ const [selectedItems, setSelectedItems] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - // Notify parent when debounced search term changes (for server-side search) - useEffect(() => { - onSearchChange?.(debouncedSearchTerm); - }, [debouncedSearchTerm, onSearchChange]); + // Server-side search is fired from the text change handler so typing and the API call stay in the same event path + const debouncedOnSearchChange = useDebounce<(term: string) => void>(onSearchChange ?? noop, CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + + const updateSearchTerm = (text: string) => { + setSearchTerm(text); + debouncedOnSearchChange(text); + }; // Snapshot the values selected when the filter first opened so they can be floated to the top of a long list on // first render without repinning rows that are toggled afterwards. @@ -137,7 +144,7 @@ function MultiSelect({ const textInputOptions: TextInputOptions = { value: searchTerm, label: isSearchable ? (searchPlaceholder ?? translate('common.search')) : undefined, - onChangeText: setSearchTerm, + onChangeText: updateSearchTerm, headerMessage, style: { containerStyle: selectionListTextInputStyle, diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 694b386b1bd8..48a699abff67 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -88,6 +88,11 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { .finally(() => setIsLoading(false)); }, []); + // Fetch the first page on mount; cached results are shown immediately and refreshed in the background + useEffect(() => { + search(''); + }, [search]); + return {searchResults, isLoading, hasMore, loadMore, search, hasCachedData}; } From 492bd0580a73171ee19ce302ff58bdae678f416f Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:17:47 +0530 Subject: [PATCH 060/485] Drop redundant useMemo around tag items now that React Compiler memoizes it --- .../Search/FilterComponents/TagSelector.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 7e8d04691372..7246d09a316e 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -7,7 +7,7 @@ import {getCleanedTagName} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; -import React, {useMemo} from 'react'; +import React from 'react'; import MultiSelect from './MultiSelect'; @@ -17,23 +17,19 @@ function TagSelector({value = [], selectionListTextInputStyle, selectionListStyl const {translate} = useLocalize(); const {searchResults, isLoading, hasMore, loadMore, search, hasCachedData} = useSearchTagFilters(); - const tagItems = useMemo(() => { - const items = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; - const seenTagNames = new Set(); - - // Preserve backend order - new items append at end for infinite scroll - for (const policyTags of Object.values(searchResults ?? {})) { - for (const tag of Object.values(policyTags ?? {})) { - if (seenTagNames.has(tag.tagName)) { - continue; - } - seenTagNames.add(tag.tagName); - items.push({text: getCleanedTagName(tag.tagName), value: tag.tagName}); + const tagItems = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; + const seenTagNames = new Set(); + + // Preserve backend order - new items append at end for infinite scroll + for (const policyTags of Object.values(searchResults ?? {})) { + for (const tag of Object.values(policyTags ?? {})) { + if (seenTagNames.has(tag.tagName)) { + continue; } + seenTagNames.add(tag.tagName); + tagItems.push({text: getCleanedTagName(tag.tagName), value: tag.tagName}); } - - return items; - }, [searchResults, translate]); + } const selectedTagsItems = value.map((tag) => { if (tag === CONST.SEARCH.TAG_EMPTY_VALUE) { From 21139dbeb773cbec9fe3be764e063cbcaa38687a Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Sun, 30 Aug 2026 16:46:01 +0000 Subject: [PATCH 061/485] Key the Spend results area on the resolved query The results area was keyed on the requested query, so a filter change mounted it with no data and flashed a skeleton in the middle of the fade. It now keys on the last query that resolved: the current results hold, then the area swaps once. - Hold applies only to filter refinements. A sidebar item or saved search is a different search, so it starts from the skeleton instead of showing rows from the query the user left. SearchQueryRefinement records which query the filter path built, since the query alone can't tell them apart. - MAX_STALE_HOLD_DURATION gives up the held results after 1s so a slow query still shows a skeleton. The wide layout has no loading bar. - SWAP_HOLD_DURATION keeps the outgoing results up while the incoming ones hydrate behind them, which hides the deferred mount's placeholder. - Drop the per-branch fades in Search: the layer owns the fade now, and a second ramp on the same content read as a double flash. --- src/CONST/index.ts | 10 ++++- .../Search/hooks/useUpdateFilterQuery.tsx | 2 + src/components/Search/index.tsx | 26 +++-------- src/libs/SearchQueryRefinement.ts | 24 ++++++++++ src/pages/Search/SearchPage.tsx | 45 ++++++++++++++++++- src/pages/Search/SearchPageNarrow/index.tsx | 38 ++++++++++------ src/pages/Search/SearchPageWide.tsx | 31 ++++++++----- 7 files changed, 131 insertions(+), 45 deletions(-) create mode 100644 src/libs/SearchQueryRefinement.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 629fb41f099d..9c85daa36cff 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7759,7 +7759,15 @@ const CONST = { SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', ANIMATION: { - FADE_DURATION: 150, + FADE_DURATION: 120, + + // How long the outgoing results stay put before they start fading. The incoming results mount straight + // away but hydrate behind them, so this window is what keeps their placeholder skeleton off screen. + SWAP_HOLD_DURATION: 100, + + // How long the results area may keep showing the previous query's results while a new query loads. Past + // this, a slow query gives up the stale results and swaps to the skeleton so the wait is visible. + MAX_STALE_HOLD_DURATION: 1000, }, TODO_BADGE_MAX_COUNT: 50, TOP_SEARCH_LIMIT: 10, diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 1a1e171dc90d..2c93d9e5bc74 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -4,6 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; +import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; @@ -49,6 +50,7 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { return; } + markQueryAsRefinement(queryString); Navigation.setParams({q: queryString, rawQuery: undefined}); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 1e0202437b55..13e67b10632b 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -88,7 +88,7 @@ import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@re import * as Sentry from '@sentry/react-native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import Animated, {FadeIn, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -1064,10 +1064,7 @@ function Search({ ) { cancelNavigationSpans(); return ( - + - + ); } @@ -1114,10 +1111,7 @@ function Search({ onLayout={onLayoutChart} scrollEventThrottle={CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE} > - + - + ); @@ -1238,15 +1232,7 @@ function Search({ isExpenseReportType={isExpenseReportType} isSearchResultsEmpty={isSearchResultsEmpty} > - - {/* The inner view owns the mount fade so it composes with the outer column-change opacity instead of fighting it. */} - - {searchListContent} - - + {searchListContent} ); diff --git a/src/libs/SearchQueryRefinement.ts b/src/libs/SearchQueryRefinement.ts new file mode 100644 index 000000000000..2f7d496f5ba7 --- /dev/null +++ b/src/libs/SearchQueryRefinement.ts @@ -0,0 +1,24 @@ +import type {SearchQueryString} from '@components/Search/types'; + +/** + * Records which search query was produced by refining the filters of the list already on screen. + * + * The results area holds the previous query's results through its swap animation so a filter tweak doesn't flash a + * skeleton. That only reads correctly for a refinement: navigating to a sidebar item or a saved search asks for a + * different search, where holding the old results would show rows unrelated to the destination. A query alone can't + * tell the two apart — both arrive as a new `q` on the same screen — so the filter path records its query here. + * + * Only the most recent query is kept: an unknown query is treated as a different search, so a missed mark costs a + * skeleton rather than showing stale results. + */ +let refinedQuery: SearchQueryString | undefined; + +function markQueryAsRefinement(query: SearchQueryString) { + refinedQuery = query; +} + +function isQueryARefinement(query: SearchQueryString | undefined) { + return !!query && refinedQuery === query; +} + +export {markQueryAsRefinement, isQueryARefinement}; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 77a6575c9e0e..f535849c158b 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,6 +1,6 @@ import {ReportSubmitToPopoverHost, SEARCH_REPORT_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; -import type {SearchParams} from '@components/Search/types'; +import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -20,8 +20,10 @@ import {searchInServer} from '@libs/actions/Report'; import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; +import {isQueryARefinement} from '@libs/SearchQueryRefinement'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {hasFilterBarsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; @@ -111,6 +113,43 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(false); }, [currentSearchResults?.isLoading, isSorting, prevIsLoading]); + const [lastResolvedSearch, setLastResolvedSearch] = useState<{queryJSON: SearchQueryJSON; searchResults: SearchResults} | undefined>(undefined); + + // Changing a filter builds a query that has never been cached, so keying the results area on the requested + // query would mount it with no data and flash a skeleton in the middle of the fade. Key it on the last query + // that actually resolved instead: the current results stay on screen until the new ones arrive, then the area + // swaps once. Adjusted during rendering for the same reason as lastNonEmptySearchResults above — the values + // below are consumed in this render, and the reference check bounds the loop to one extra pass. + // isCurrentSearchResolved, not a raw hash comparison: a response folds sort defaults into its own hash, so the + // requested and returned hashes legitimately differ and isSearchDataLoaded is what reconciles them. + const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; + if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { + setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); + } + + // A slow query would otherwise leave the previous results up indefinitely with nothing to show a wait is happening + // (the wide layout has no loading bar). Keyed by hash rather than reset on resolve so the effect never has to call + // setState synchronously; a hash that no longer matches simply stops counting. + const [staleHoldTimedOutHash, setStaleHoldTimedOutHash] = useState(undefined); + const currentQueryHash = currentSearchQueryJSON?.hash; + + useEffect(() => { + if (isSearchResolvedForCurrentQuery || currentQueryHash === undefined) { + return; + } + + const timeoutID = setTimeout(() => setStaleHoldTimedOutHash(currentQueryHash), CONST.SEARCH.ANIMATION.MAX_STALE_HOLD_DURATION); + return () => clearTimeout(timeoutID); + }, [isSearchResolvedForCurrentQuery, currentQueryHash]); + + const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; + + // Only a filter refinement is worth holding for. A sidebar item or saved search is a different search, so its + // results area starts from the skeleton rather than showing rows that belong to the query the user just left. + const shouldHoldLastResolvedSearch = !isSearchResolvedForCurrentQuery && !!lastResolvedSearch && !hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery); + const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; + const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; + const handleSearchAction = useCallback((value: SearchParams | string) => { if (typeof value === 'string') { searchInServer(value); @@ -143,6 +182,8 @@ function SearchPage({route}: SearchPageProps) { void; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ @@ -72,6 +79,8 @@ const tabBarContent = {isInteractive && ( {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* Changing the search type or a filter changes the hash, which remounts this layer: the outgoing one - fades out and the incoming one waits for it to finish before fading in. Both layers are absolutely - filled so the outgoing fade overlays the incoming layer instead of sharing the column layout. */} + {/* A resolved query change remounts this layer. The outgoing one holds, then fades out; the incoming + one stays hidden until that finishes, which also hides its own mount. Both layers are absolutely + filled so they stack during the hold instead of sharing the column layout. */} {shouldShowLoadingSkeleton ? ( ) : ( ; + + /** The last query whose results resolved. Drives the results area so it holds the current results while a new query loads. */ + contentQueryJSON?: SearchQueryJSON; + + /** Results for `contentQueryJSON`. */ + contentSearchResults: OnyxEntry; + isMobileSelectionModeEnabled: boolean; handleSearchAction: (value: SearchParams | string) => void; onSortPressedCallback: () => void; @@ -50,6 +57,8 @@ type SearchPageWideProps = { function SearchPageWide({ queryJSON, searchResults, + contentQueryJSON, + contentSearchResults, isMobileSelectionModeEnabled, handleSearchAction, onSortPressedCallback, @@ -57,7 +66,7 @@ function SearchPageWide({ searchOverlayContent, onSearchContentReady, }: SearchPageWideProps) { - const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); + const shouldShowLoadingSkeleton = useSearchLoadingState(contentQueryJSON, contentSearchResults); const styles = useThemeStyles(); const {currentSearchKey} = useSearchQueryContext(); const {hasSelectedTransactions} = useSearchSelectionContext(); @@ -113,7 +122,7 @@ function SearchPageWide({ onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - {!!queryJSON && ( + {!!queryJSON && !!contentQueryJSON && ( <> {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* Changing the search type or a filter changes the hash, which remounts this layer: the outgoing one - fades out and the incoming one waits for it to finish before fading in. Both layers are absolutely - filled so the outgoing fade overlays the incoming layer instead of sharing the column layout. */} + {/* A resolved query change remounts this layer. The outgoing one holds, then fades out; the incoming + one stays hidden until that finishes, which also hides its own mount. Both layers are absolutely + filled so they stack during the hold instead of sharing the column layout. */} {shouldShowLoadingSkeleton ? ( ) : ( Date: Sun, 30 Aug 2026 22:27:16 +0530 Subject: [PATCH 062/485] Cancel in-flight tag filter requests when a new search supersedes them --- src/hooks/useSearchTagFilters.ts | 40 ++++++++++++++----- .../OpenSearchTagFiltersPageParams.ts | 3 ++ src/libs/HttpUtils.ts | 2 + src/libs/actions/Search.ts | 9 ++++- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 48a699abff67..4b4cfb168b73 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -31,6 +31,14 @@ type UseSearchTagFiltersResult = { hasCachedData: boolean; }; +/** Logs tag filter request failures; aborted requests are expected when a newer search supersedes them */ +function logRequestFailure(message: string, error: Error) { + if (error.name === CONST.ERROR.REQUEST_CANCELLED) { + return; + } + Log.warn(message, {error}); +} + /** * Hook for managing paginated tag filter search. * Handles API calls, pagination state, and Onyx data subscription. @@ -55,37 +63,51 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { stateRef.current = {hasMore, nextCursor, searchQuery, hasCachedData, isLoading}; }, [hasMore, nextCursor, searchQuery, hasCachedData, isLoading]); + // Incremented on every new search so a cancelled request doesn't clear the loading state of its successor + const requestSeqRef = useRef(0); + const loadMore = useCallback(() => { const {hasMore: currentHasMore, nextCursor: currentCursor, searchQuery: currentQuery, isLoading: currentIsLoading} = stateRef.current; if (currentIsLoading || !currentHasMore) { return; } + const requestSeq = requestSeqRef.current; setIsLoading(true); openSearchTagFiltersPage({searchQuery: currentQuery, cursor: currentCursor, limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { setSearchTagFiltersPagination(newHasMore, newCursor, currentQuery); }) - .catch((error: Error) => Log.warn('Failed to load the next tag filters page', {error})) - .finally(() => setIsLoading(false)); + .catch((error: Error) => logRequestFailure('Failed to load the next tag filters page', error)) + .finally(() => { + if (requestSeq !== requestSeqRef.current) { + return; + } + setIsLoading(false); + }); }, []); const search = useCallback((query: string) => { const {hasCachedData: currentHasCachedData} = stateRef.current; + const requestSeq = ++requestSeqRef.current; // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor setSearchTagFiltersPagination(false, '', query); - // Only show loading if no cached data - otherwise fetch silently in background - if (!currentHasCachedData) { - setIsLoading(true); - } + // A new search cancels any in-flight request, so it owns the loading state from here on. + // With cached data the fetch runs silently in the background. + setIsLoading(!currentHasCachedData); - openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) + openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}, true) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { setSearchTagFiltersPagination(newHasMore, newCursor, query); }) - .catch((error: Error) => Log.warn('Failed to fetch tag filters', {error})) - .finally(() => setIsLoading(false)); + .catch((error: Error) => logRequestFailure('Failed to fetch tag filters', error)) + .finally(() => { + if (requestSeq !== requestSeqRef.current) { + return; + } + setIsLoading(false); + }); }, []); // Fetch the first page on mount; cached results are shown immediately and refreshed in the background diff --git a/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts b/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts index 385841139c46..9b690306dff6 100644 --- a/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts +++ b/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts @@ -7,6 +7,9 @@ type OpenSearchTagFiltersPageParams = { /** Maximum number of tags to return per page */ limit?: number; + + /** Whether the request can be aborted when superseded by a newer search */ + canCancel?: boolean; }; export default OpenSearchTagFiltersPageParams; diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index 0c1ea1735830..6b39be1b2377 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -32,6 +32,7 @@ const ABORT_COMMANDS = { All: 'All', [READ_COMMANDS.SEARCH_FOR_REPORTS]: READ_COMMANDS.SEARCH_FOR_REPORTS, [READ_COMMANDS.SEARCH_FOR_USERS]: READ_COMMANDS.SEARCH_FOR_USERS, + [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE]: SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, } as const; type AbortCommand = keyof typeof ABORT_COMMANDS; @@ -53,6 +54,7 @@ const abortControllerMap = new Map(); abortControllerMap.set(ABORT_COMMANDS.All, new AbortController()); abortControllerMap.set(ABORT_COMMANDS.SearchForReports, new AbortController()); abortControllerMap.set(ABORT_COMMANDS.SearchForUsers, new AbortController()); +abortControllerMap.set(ABORT_COMMANDS.OpenSearchTagFiltersPage, new AbortController()); /** * The API commands that require the skew calculation diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index de4bb2f6d76d..2e1dce3ccf3f 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -28,6 +28,7 @@ import deferModalPresentationAfterPopoverDismiss from '@libs/deferModalPresentat import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import {getExportFileName} from '@libs/fileDownload/FileUtils'; +import HttpUtils from '@libs/HttpUtils'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; @@ -1048,9 +1049,13 @@ function openSearchCategoryFiltersPage() { /** * Fetches a page of tag filter search results from the server. * Returns pagination metadata (hasMore, nextCursor) for infinite scroll. + * A new search passes `shouldCancelPendingRequests` so a superseded in-flight request cannot overwrite the fresh results. */ -function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams): Promise<{hasMore: boolean; nextCursor: string}> { - return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, params).then((response) => ({ +function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams, shouldCancelPendingRequests = false): Promise<{hasMore: boolean; nextCursor: string}> { + if (shouldCancelPendingRequests) { + HttpUtils.cancelPendingRequests(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE); + } + return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}).then((response) => ({ hasMore: !!response?.hasMore, nextCursor: response?.nextCursor ?? '', })); From 441c0494e6392101dd813e666794357ed4b23a9f Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Sun, 30 Aug 2026 17:22:49 +0000 Subject: [PATCH 063/485] Add haptic feedback when the Spend query changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit impactLight via HapticFeedback.press(), on the tap rather than on the swap animation: a slow query would otherwise land the buzz up to a second after the press. Covers the four entry points that change the query — sidebar type items and router suggestions, saved searches, narrow tabs, and filter changes. Native only; the web implementation is a no-op. --- src/components/Search/hooks/useUpdateFilterQuery.tsx | 2 ++ src/libs/SearchNavigationUtils.ts | 2 ++ src/pages/Search/SavedSearchList.tsx | 2 ++ src/pages/Search/SearchTypeMenuNarrow.tsx | 2 ++ 4 files changed, 8 insertions(+) diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 2c93d9e5bc74..e3d5fab60807 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -3,6 +3,7 @@ import type {SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import HapticFeedback from '@libs/HapticFeedback'; import Navigation from '@libs/Navigation/Navigation'; import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; @@ -50,6 +51,7 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { return; } + HapticFeedback.press(); markQueryAsRefinement(queryString); Navigation.setParams({q: queryString, rawQuery: undefined}); } diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index 925faf6c3590..ec8aa8a3989c 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -1,9 +1,11 @@ import ROUTES from '@src/ROUTES'; import {setSearchContext} from './actions/Search'; +import HapticFeedback from './HapticFeedback'; import Navigation from './Navigation/Navigation'; function navigateToCannedSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { + HapticFeedback.press(); clearSelectedTransactions(); setSearchContext(false); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index a3c5e060fb56..c2d6c8781cfd 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -14,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; +import HapticFeedback from '@libs/HapticFeedback'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; @@ -61,6 +62,7 @@ function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMen role: CONST.ROLE.TAB, sentryLabel: CONST.SENTRY_LABEL.SEARCH.SAVED_SEARCH_MENU_ITEM, onPress: () => { + HapticFeedback.press(); setSearchContext(false); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); }, diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index a2b6a059232f..596284da3875 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -21,6 +21,7 @@ import useTodoCounts from '@hooks/useTodoCounts'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; +import HapticFeedback from '@libs/HapticFeedback'; import {getAllTaxRates} from '@libs/PolicyUtils'; import {getItemBadgeText, getOverflowMenu, SAVED_SEARCH_FALLBACK_ICON_NAME, SAVED_SEARCH_ICON_NAMES, SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; @@ -221,6 +222,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps if (!searchData) { return; } + HapticFeedback.press(); onTabPress?.(); setSearchContext(false); navigation.dispatch({ From a0987be79712e5f3e954b146ca5fe68500ca7fab Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:07:48 +0530 Subject: [PATCH 064/485] Remove unused policyID param from useAdvancedSearchFilters --- .../Search/FilterComponents/AdvancedFilters/FilterList.tsx | 6 ++---- .../FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx | 2 -- src/hooks/useAdvancedSearchFilters.ts | 5 +---- .../SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx | 2 -- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx index ad89d06543a5..0de9291980ef 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx @@ -1,7 +1,6 @@ import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import ScrollView from '@components/ScrollView'; -import type {Filter} from '@components/Search/types'; import SpacerView from '@components/SpacerView'; import Text from '@components/Text'; @@ -34,7 +33,6 @@ type FilterItemCallbacks = { type FilterListProps = FilterItemCallbacks & { type: SearchDataTypes | undefined; - policyID: Filter; selectedFilter?: SearchFilter['key']; style?: StyleProp; contentContainerStyle?: StyleProp; @@ -102,9 +100,9 @@ function FilterItem({filterKey, isSelected, onPress, onHoverIn, onFocus}: Filter ); } -function FilterList({type, policyID, selectedFilter, style, contentContainerStyle, onHoverIn, onFocus, onPress}: FilterListProps) { +function FilterList({type, selectedFilter, style, contentContainerStyle, onHoverIn, onFocus, onPress}: FilterListProps) { const styles = useThemeStyles(); - const typeFiltersKeys = useAdvancedSearchFilters(type, policyID); + const typeFiltersKeys = useAdvancedSearchFilters(type); return ( ) { return shouldDisplayFilter(Object.keys(filterCardsHiddenFromSearch(cardList)).length, true); } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function useAdvancedSearchFilters(type: SearchDataTypes | undefined, _policyID: Filter | undefined) { +function useAdvancedSearchFilters(type: SearchDataTypes | undefined) { const [shouldDisplayCardFilter] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: shouldDisplayCardFilterSelector}); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: advancedSearchPoliciesSelector}); const [policyDerived] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policyDerivedSelector}); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx index 6c3c67642277..44ad61c47ed2 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx @@ -7,7 +7,6 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {getFilterNegatableValue} from '@libs/SearchUIUtils'; import {SearchAdvancedFiltersActionContext, SearchAdvancedFiltersContext} from '@pages/Search/SearchAdvancedFiltersProvider'; @@ -33,7 +32,6 @@ function SearchAdvancedFiltersBase() { Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.getRoute(filterKey))} /> {shouldShowResetFilters && ( From 023ca743915e2a075231db320680ba319d878ef3 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Sun, 30 Aug 2026 17:53:51 +0000 Subject: [PATCH 065/485] Add haptic feedback to navigation tab bar Same impactLight press as the Spend query changes, on Home, Spend, Workspaces and Profile. Each fires only when the press actually leaves the current tab: Home, Spend and Profile already return early when selected, and Workspaces gets an equivalent check because pressing it while selected restores the last workspace route instead of switching tabs. --- .../NavigationTabBar/SearchTabButton.tsx | 2 ++ .../NavigationTabBar/WorkspacesTabButton.tsx | 15 +++++++++++++-- .../Navigation/NavigationTabBar/index.tsx | 3 +++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 3cf63c6ea929..4280f4b1ef36 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -6,6 +6,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; +import HapticFeedback from '@libs/HapticFeedback'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import {startSpan} from '@libs/telemetry/activeSpans'; @@ -40,6 +41,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { if (selectedTab === NAVIGATION_TABS.SEARCH) { return; } + HapticFeedback.press(); clearSelectedText(); interceptAnonymousUser(() => { startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { diff --git a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx index f996c54f0522..9ad4de6cc8b2 100644 --- a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx @@ -6,6 +6,8 @@ import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnN import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; +import HapticFeedback from '@libs/HapticFeedback'; + import CONST from '@src/CONST'; import type {ValueOf} from 'type-fest'; @@ -28,13 +30,22 @@ function WorkspacesTabButton({selectedTab, isWideLayout}: WorkspacesTabButtonPro const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); + // Pressing the tab you're already on restores the last workspace route rather than moving tabs, so it gets no + // haptic. The other tabs get the same treatment through their own early returns. + const handleWorkspacesPress = () => { + if (selectedTab !== NAVIGATION_TABS.WORKSPACES) { + HapticFeedback.press(); + } + navigateToWorkspaces(); + }; + const workspacesAccessibilityState = {selected: selectedTab === NAVIGATION_TABS.WORKSPACES}; const workspacesStatusIndicatorColor = workspacesTabIndicatorStatus ? workspacesTabIndicatorColor : undefined; if (isWideLayout) { return ( { Navigation.navigate(ROUTES.SETTINGS); }); From 2dde80a1ebe975bf3914c96111da044d9c03bdd9 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:55:31 +0530 Subject: [PATCH 066/485] Update useAdvancedSearchFilters tests for single-arg signature --- .../useAdvancedSearchFilters.perf-test.tsx | 2 +- .../hooks/useAdvancedSearchFilters.test.ts | 44 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/perf-test/useAdvancedSearchFilters.perf-test.tsx b/tests/perf-test/useAdvancedSearchFilters.perf-test.tsx index 62613cc84ada..f264b8b8ec66 100644 --- a/tests/perf-test/useAdvancedSearchFilters.perf-test.tsx +++ b/tests/perf-test/useAdvancedSearchFilters.perf-test.tsx @@ -76,7 +76,7 @@ jest.mock('@react-navigation/native', () => { function TestComponent() { const currentType = 'expense'; - const filters = useAdvancedSearchFilters(currentType, undefined); + const filters = useAdvancedSearchFilters(currentType); return ; } diff --git a/tests/unit/hooks/useAdvancedSearchFilters.test.ts b/tests/unit/hooks/useAdvancedSearchFilters.test.ts index 2a5bc234191d..c99c72eb5a2f 100644 --- a/tests/unit/hooks/useAdvancedSearchFilters.test.ts +++ b/tests/unit/hooks/useAdvancedSearchFilters.test.ts @@ -105,7 +105,7 @@ describe('useAdvancedSearchFilters', () => { const policy = buildPolicy(1, {areCategoriesEnabled: false}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -119,7 +119,7 @@ describe('useAdvancedSearchFilters', () => { const policy = buildPolicy(1, {areTagsEnabled: false}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -132,7 +132,7 @@ describe('useAdvancedSearchFilters', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}1`, buildTagList('Engineering')); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -144,7 +144,7 @@ describe('useAdvancedSearchFilters', () => { const policy = buildPolicy(1, {areTagsEnabled: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -156,7 +156,7 @@ describe('useAdvancedSearchFilters', () => { const policy = buildPolicy(1, {areTagsEnabled: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, {value: ['1'], isNegated: false}), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -169,7 +169,7 @@ describe('useAdvancedSearchFilters', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}1`, buildTagList('Engineering')); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, {value: ['1'], isNegated: false}), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -183,7 +183,7 @@ describe('useAdvancedSearchFilters', () => { const policy = buildPolicy(1, {tax: {trackingEnabled: false}}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -206,7 +206,7 @@ describe('useAdvancedSearchFilters', () => { }); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -227,7 +227,7 @@ describe('useAdvancedSearchFilters', () => { }); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -241,7 +241,7 @@ describe('useAdvancedSearchFilters', () => { const policy = buildPolicy(1, {type: CONST.POLICY.TYPE.CORPORATE, isAttendeeTrackingEnabled: false}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -255,7 +255,7 @@ describe('useAdvancedSearchFilters', () => { delete (policy as Record).isAttendeeTrackingEnabled; await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -267,7 +267,7 @@ describe('useAdvancedSearchFilters', () => { const policy = buildPolicy(1, {type: CONST.POLICY.TYPE.CORPORATE, isAttendeeTrackingEnabled: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -281,7 +281,7 @@ describe('useAdvancedSearchFilters', () => { const policy = buildPolicy(1, {fieldList: {}}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -311,7 +311,7 @@ describe('useAdvancedSearchFilters', () => { }); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -341,7 +341,7 @@ describe('useAdvancedSearchFilters', () => { }); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -352,7 +352,7 @@ describe('useAdvancedSearchFilters', () => { describe('bank account filter visibility', () => { it('hides bank account filter when no bank accounts exist', async () => { - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -374,7 +374,7 @@ describe('useAdvancedSearchFilters', () => { }, }); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -396,7 +396,7 @@ describe('useAdvancedSearchFilters', () => { }, }); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -418,7 +418,7 @@ describe('useAdvancedSearchFilters', () => { }, }); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -440,7 +440,7 @@ describe('useAdvancedSearchFilters', () => { }, }); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -462,7 +462,7 @@ describe('useAdvancedSearchFilters', () => { }, }); - const {result} = renderHook(() => useAdvancedSearchFilters(CONST.SEARCH.DATA_TYPES.CHAT, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(CONST.SEARCH.DATA_TYPES.CHAT), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); @@ -491,7 +491,7 @@ describe('useAdvancedSearchFilters', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}1`, buildTagList('Engineering')); - const {result} = renderHook(() => useAdvancedSearchFilters(undefined, undefined), {wrapper}); + const {result} = renderHook(() => useAdvancedSearchFilters(undefined), {wrapper}); await waitFor(() => { const allKeys = result.current.flat(); From 9801a6e0b5199e2a11ab54a935ddf9cb378f412a Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:55:33 +0530 Subject: [PATCH 067/485] Show full-list spinner only on initial tag filter load --- .../Search/FilterComponents/TagSelector.tsx | 4 ++-- src/hooks/useSearchTagFilters.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 7246d09a316e..b714280f641d 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -15,7 +15,7 @@ type TagSelectorProps = SearchFilterCommonProps; function TagSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); - const {searchResults, isLoading, hasMore, loadMore, search, hasCachedData} = useSearchTagFilters(); + const {searchResults, isLoading, hasMore, loadMore, search, isInitialLoading} = useSearchTagFilters(); const tagItems = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; const seenTagNames = new Set(); @@ -50,7 +50,7 @@ function TagSelector({value = [], selectionListTextInputStyle, selectionListStyl onChange={(tags) => onChange(tags.map((tag) => tag.value))} onEndReached={hasMore ? loadMore : undefined} onSearchChange={search} - loading={isLoading && tagItems.length <= 1 && !hasCachedData} + loading={isInitialLoading} isLoadingMore={isLoading && tagItems.length > 1} /> ); diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 4b4cfb168b73..f5365a0832fb 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -27,8 +27,8 @@ type UseSearchTagFiltersResult = { /** Start a new search with the given query */ search: (query: string) => void; - /** Whether there are cached results from a previous fetch */ - hasCachedData: boolean; + /** Whether the first fetch is still in flight with no results to show yet */ + isInitialLoading: boolean; }; /** Logs tag filter request failures; aborted requests are expected when a newer search supersedes them */ @@ -66,6 +66,10 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { // Incremented on every new search so a cancelled request doesn't clear the loading state of its successor const requestSeqRef = useRef(0); + // Gates the full-list spinner to the initial load. Once any search settles, the list stays mounted + // so the search input keeps focus while the user types, even when the latest results are empty. + const hasCompletedSearchRef = useRef(hasCachedData); + const loadMore = useCallback(() => { const {hasMore: currentHasMore, nextCursor: currentCursor, searchQuery: currentQuery, isLoading: currentIsLoading} = stateRef.current; if (currentIsLoading || !currentHasMore) { @@ -106,6 +110,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { if (requestSeq !== requestSeqRef.current) { return; } + hasCompletedSearchRef.current = true; setIsLoading(false); }); }, []); @@ -115,7 +120,9 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { search(''); }, [search]); - return {searchResults, isLoading, hasMore, loadMore, search, hasCachedData}; + const isInitialLoading = isLoading && !hasCompletedSearchRef.current; + + return {searchResults, isLoading, hasMore, loadMore, search, isInitialLoading}; } export default useSearchTagFilters; From d283f3d035e61b80fcb4356de8771f2a8cac4f30 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:58:25 +0530 Subject: [PATCH 068/485] Track completed tag search with state instead of ref --- src/hooks/useSearchTagFilters.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index f5365a0832fb..ccd50c0b75a0 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -68,7 +68,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { // Gates the full-list spinner to the initial load. Once any search settles, the list stays mounted // so the search input keeps focus while the user types, even when the latest results are empty. - const hasCompletedSearchRef = useRef(hasCachedData); + const [hasCompletedSearch, setHasCompletedSearch] = useState(hasCachedData); const loadMore = useCallback(() => { const {hasMore: currentHasMore, nextCursor: currentCursor, searchQuery: currentQuery, isLoading: currentIsLoading} = stateRef.current; @@ -110,7 +110,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { if (requestSeq !== requestSeqRef.current) { return; } - hasCompletedSearchRef.current = true; + setHasCompletedSearch(true); setIsLoading(false); }); }, []); @@ -120,7 +120,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { search(''); }, [search]); - const isInitialLoading = isLoading && !hasCompletedSearchRef.current; + const isInitialLoading = isLoading && !hasCompletedSearch; return {searchResults, isLoading, hasMore, loadMore, search, isInitialLoading}; } From f1e0a2256b82ce86b7c0eeb28a29a32b22949292 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Mon, 31 Aug 2026 00:38:49 +0000 Subject: [PATCH 069/485] Shorten the Spend swap hold to 60ms --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9c85daa36cff..f5f8c1142d86 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7763,7 +7763,7 @@ const CONST = { // How long the outgoing results stay put before they start fading. The incoming results mount straight // away but hydrate behind them, so this window is what keeps their placeholder skeleton off screen. - SWAP_HOLD_DURATION: 100, + SWAP_HOLD_DURATION: 60, // How long the results area may keep showing the previous query's results while a new query loads. Past // this, a slow query gives up the stale results and swaps to the skeleton so the wait is visible. From a2f0c69b61d946aee1f104e7c953cfa442180238 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Mon, 31 Aug 2026 00:51:26 +0000 Subject: [PATCH 070/485] Drop the Spend swap hold and shorten the stale hold The hold gave the incoming results a head start so their placeholder skeleton stayed off screen. Keying the layer on the resolved query made it redundant: the data is already in memory when the layer mounts, so the hydrate lands within a frame. The swap is now a 120ms fade out followed by a 120ms fade in. MAX_STALE_HOLD_DURATION 1000 -> 500, so a slow query gives up the previous results sooner. --- src/CONST/index.ts | 6 +----- src/pages/Search/SearchPageNarrow/index.tsx | 12 +++++------- src/pages/Search/SearchPageWide.tsx | 12 +++++------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f5f8c1142d86..c0445508a820 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7761,13 +7761,9 @@ const CONST = { ANIMATION: { FADE_DURATION: 120, - // How long the outgoing results stay put before they start fading. The incoming results mount straight - // away but hydrate behind them, so this window is what keeps their placeholder skeleton off screen. - SWAP_HOLD_DURATION: 60, - // How long the results area may keep showing the previous query's results while a new query loads. Past // this, a slow query gives up the stale results and swaps to the skeleton so the wait is visible. - MAX_STALE_HOLD_DURATION: 1000, + MAX_STALE_HOLD_DURATION: 500, }, TODO_BADGE_MAX_COUNT: 50, TOP_SEARCH_LIMIT: 10, diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index e217e00ae200..771e501f05ba 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -346,15 +346,13 @@ function SearchPageNarrow({ <> {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A resolved query change remounts this layer. The outgoing one holds, then fades out; the incoming - one stays hidden until that finishes, which also hides its own mount. Both layers are absolutely - filled so they stack during the hold instead of sharing the column layout. */} + {/* A resolved query change remounts this layer: the outgoing one fades out and the incoming one waits + for it to finish before fading in. Both layers are absolutely filled so the outgoing fade overlays + the incoming layer instead of sharing the column layout. */} {shouldShowLoadingSkeleton ? ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index d8f95d3fc3ab..b3470773c8c7 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -133,15 +133,13 @@ function SearchPageWide({ {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A resolved query change remounts this layer. The outgoing one holds, then fades out; the incoming - one stays hidden until that finishes, which also hides its own mount. Both layers are absolutely - filled so they stack during the hold instead of sharing the column layout. */} + {/* A resolved query change remounts this layer: the outgoing one fades out and the incoming one waits + for it to finish before fading in. Both layers are absolutely filled so the outgoing fade overlays + the incoming layer instead of sharing the column layout. */} {shouldShowLoadingSkeleton ? ( From 5e03d0cb0c3acde22934c1fe2c5965fb4a4bf2e2 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Mon, 31 Aug 2026 00:54:42 +0000 Subject: [PATCH 071/485] Add the missing haptic on the Inbox tab The Inbox tab is rendered by InboxTabButton, which the tab bar haptics commit skipped. Both of its navigateToChats handlers now fire the press before branching, so every navigation path the wide layout takes gets it. --- src/components/Navigation/NavigationTabBar/InboxTabButton.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index b35767100788..8db47b2a81a6 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -8,6 +8,7 @@ import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import HapticFeedback from '@libs/HapticFeedback'; import Navigation, {startOpenReportSpan} from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {isDeletedAction} from '@libs/ReportActionsUtils'; @@ -106,6 +107,7 @@ function WideInboxTabButton({selectedTab, statusIndicatorColor, accessibilityLab return; } + HapticFeedback.press(); startNavigateToInboxTabSpan({isWideLayout: true}); if (doesLastReportExist) { @@ -225,6 +227,7 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { return; } + HapticFeedback.press(); startNavigateToInboxTabSpan({isWideLayout: false}); Navigation.navigate(ROUTES.INBOX); }; From 4ddc055a87324363b96eb0b07783203b351e9374 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Mon, 31 Aug 2026 01:38:56 +0000 Subject: [PATCH 072/485] Replace the Spend stale-results hold with a grace window Holding the previous query's results needed a marker to tell a filter refinement from a jump to another search, and it still showed rows from the page the user just left when that other search wasn't cached. The results area keys on the requested query again. The incoming layer renders straight away but stays hidden for a grace window when its results aren't in memory yet, so a query that resolves inside that window fades directly to its results and only a slower one reveals the skeleton. A cached swap skips the window entirely. Removes SearchQueryRefinement, the held pair and its timeout, and the content-pair props on both page components. FADE_DURATION 120 -> 200. --- src/CONST/index.ts | 9 ++-- .../Search/hooks/useUpdateFilterQuery.tsx | 2 - src/libs/SearchQueryRefinement.ts | 24 ---------- src/pages/Search/SearchPage.tsx | 45 +------------------ src/pages/Search/SearchPageNarrow/index.tsx | 38 +++++++--------- src/pages/Search/SearchPageWide.tsx | 31 ++++++------- 6 files changed, 37 insertions(+), 112 deletions(-) delete mode 100644 src/libs/SearchQueryRefinement.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c0445508a820..fbe805bba112 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7759,11 +7759,12 @@ const CONST = { SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', ANIMATION: { - FADE_DURATION: 120, + FADE_DURATION: 200, - // How long the results area may keep showing the previous query's results while a new query loads. Past - // this, a slow query gives up the stale results and swaps to the skeleton so the wait is visible. - MAX_STALE_HOLD_DURATION: 500, + // How long the results area stays blank after the outgoing results fade out, waiting for the incoming ones. + // A query that resolves inside this window fades straight in; a slower one reveals the skeleton instead. + // Skipped when the incoming results are already in memory, so a cached swap has no gap at all. + SKELETON_GRACE_DURATION: 200, }, TODO_BADGE_MAX_COUNT: 50, TOP_SEARCH_LIMIT: 10, diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index e3d5fab60807..6725458e1897 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -5,7 +5,6 @@ import useOnyx from '@hooks/useOnyx'; import HapticFeedback from '@libs/HapticFeedback'; import Navigation from '@libs/Navigation/Navigation'; -import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; @@ -52,7 +51,6 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { } HapticFeedback.press(); - markQueryAsRefinement(queryString); Navigation.setParams({q: queryString, rawQuery: undefined}); } diff --git a/src/libs/SearchQueryRefinement.ts b/src/libs/SearchQueryRefinement.ts deleted file mode 100644 index 2f7d496f5ba7..000000000000 --- a/src/libs/SearchQueryRefinement.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {SearchQueryString} from '@components/Search/types'; - -/** - * Records which search query was produced by refining the filters of the list already on screen. - * - * The results area holds the previous query's results through its swap animation so a filter tweak doesn't flash a - * skeleton. That only reads correctly for a refinement: navigating to a sidebar item or a saved search asks for a - * different search, where holding the old results would show rows unrelated to the destination. A query alone can't - * tell the two apart — both arrive as a new `q` on the same screen — so the filter path records its query here. - * - * Only the most recent query is kept: an unknown query is treated as a different search, so a missed mark costs a - * skeleton rather than showing stale results. - */ -let refinedQuery: SearchQueryString | undefined; - -function markQueryAsRefinement(query: SearchQueryString) { - refinedQuery = query; -} - -function isQueryARefinement(query: SearchQueryString | undefined) { - return !!query && refinedQuery === query; -} - -export {markQueryAsRefinement, isQueryARefinement}; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f535849c158b..77a6575c9e0e 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,6 +1,6 @@ import {ReportSubmitToPopoverHost, SEARCH_REPORT_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; -import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; +import type {SearchParams} from '@components/Search/types'; import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -20,10 +20,8 @@ import {searchInServer} from '@libs/actions/Report'; import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; -import {isQueryARefinement} from '@libs/SearchQueryRefinement'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {hasFilterBarsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; @@ -113,43 +111,6 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(false); }, [currentSearchResults?.isLoading, isSorting, prevIsLoading]); - const [lastResolvedSearch, setLastResolvedSearch] = useState<{queryJSON: SearchQueryJSON; searchResults: SearchResults} | undefined>(undefined); - - // Changing a filter builds a query that has never been cached, so keying the results area on the requested - // query would mount it with no data and flash a skeleton in the middle of the fade. Key it on the last query - // that actually resolved instead: the current results stay on screen until the new ones arrive, then the area - // swaps once. Adjusted during rendering for the same reason as lastNonEmptySearchResults above — the values - // below are consumed in this render, and the reference check bounds the loop to one extra pass. - // isCurrentSearchResolved, not a raw hash comparison: a response folds sort defaults into its own hash, so the - // requested and returned hashes legitimately differ and isSearchDataLoaded is what reconciles them. - const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; - if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { - setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); - } - - // A slow query would otherwise leave the previous results up indefinitely with nothing to show a wait is happening - // (the wide layout has no loading bar). Keyed by hash rather than reset on resolve so the effect never has to call - // setState synchronously; a hash that no longer matches simply stops counting. - const [staleHoldTimedOutHash, setStaleHoldTimedOutHash] = useState(undefined); - const currentQueryHash = currentSearchQueryJSON?.hash; - - useEffect(() => { - if (isSearchResolvedForCurrentQuery || currentQueryHash === undefined) { - return; - } - - const timeoutID = setTimeout(() => setStaleHoldTimedOutHash(currentQueryHash), CONST.SEARCH.ANIMATION.MAX_STALE_HOLD_DURATION); - return () => clearTimeout(timeoutID); - }, [isSearchResolvedForCurrentQuery, currentQueryHash]); - - const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; - - // Only a filter refinement is worth holding for. A sidebar item or saved search is a different search, so its - // results area starts from the skeleton rather than showing rows that belong to the query the user just left. - const shouldHoldLastResolvedSearch = !isSearchResolvedForCurrentQuery && !!lastResolvedSearch && !hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery); - const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; - const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; - const handleSearchAction = useCallback((value: SearchParams | string) => { if (typeof value === 'string') { searchInServer(value); @@ -182,8 +143,6 @@ function SearchPage({route}: SearchPageProps) { void; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ @@ -79,8 +73,6 @@ const tabBarContent = {isInteractive && ( {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A resolved query change remounts this layer: the outgoing one fades out and the incoming one waits - for it to finish before fading in. Both layers are absolutely filled so the outgoing fade overlays - the incoming layer instead of sharing the column layout. */} + {/* A query change remounts this layer: the outgoing one fades out, and the incoming one stays hidden until + that finishes. When the new results aren't in memory yet it waits a further grace window, so a query + that resolves quickly fades straight to its results rather than through a skeleton. Both layers are + absolutely filled so the outgoing fade overlays the incoming one instead of sharing the column layout. */} @@ -359,8 +355,8 @@ function SearchPageNarrow({ ) : ( ; - /** The last query whose results resolved. Drives the results area so it holds the current results while a new query loads. */ - contentQueryJSON?: SearchQueryJSON; - - /** Results for `contentQueryJSON`. */ - contentSearchResults: OnyxEntry; - isMobileSelectionModeEnabled: boolean; handleSearchAction: (value: SearchParams | string) => void; onSortPressedCallback: () => void; @@ -57,8 +51,6 @@ type SearchPageWideProps = { function SearchPageWide({ queryJSON, searchResults, - contentQueryJSON, - contentSearchResults, isMobileSelectionModeEnabled, handleSearchAction, onSortPressedCallback, @@ -66,7 +58,7 @@ function SearchPageWide({ searchOverlayContent, onSearchContentReady, }: SearchPageWideProps) { - const shouldShowLoadingSkeleton = useSearchLoadingState(contentQueryJSON, contentSearchResults); + const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const styles = useThemeStyles(); const {currentSearchKey} = useSearchQueryContext(); const {hasSelectedTransactions} = useSearchSelectionContext(); @@ -122,7 +114,7 @@ function SearchPageWide({ onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - {!!queryJSON && !!contentQueryJSON && ( + {!!queryJSON && ( <> {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A resolved query change remounts this layer: the outgoing one fades out and the incoming one waits - for it to finish before fading in. Both layers are absolutely filled so the outgoing fade overlays - the incoming layer instead of sharing the column layout. */} + {/* A query change remounts this layer: the outgoing one fades out, and the incoming one stays hidden until + that finishes. When the new results aren't in memory yet it waits a further grace window, so a query + that resolves quickly fades straight to its results rather than through a skeleton. Both layers are + absolutely filled so the outgoing fade overlays the incoming one instead of sharing the column layout. */} @@ -146,8 +143,8 @@ function SearchPageWide({ ) : ( Date: Mon, 31 Aug 2026 18:30:46 +0530 Subject: [PATCH 073/485] use boolean coercion for tag filter visibility --- src/hooks/useAdvancedSearchFilters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useAdvancedSearchFilters.ts b/src/hooks/useAdvancedSearchFilters.ts index 90bcd8d1deb5..fa6af7a0e165 100644 --- a/src/hooks/useAdvancedSearchFilters.ts +++ b/src/hooks/useAdvancedSearchFilters.ts @@ -314,7 +314,7 @@ function useAdvancedSearchFilters(type: SearchDataTypes | undefined) { const {workspaces} = useAdvancedSearchFiltersWorkspaces(policies); - const shouldDisplayTagFilter = policyDerived?.areTagsEnabled ?? false; + const shouldDisplayTagFilter = !!policyDerived?.areTagsEnabled; // Count business accounts that aren't partially set up, mirroring BankAccountSelector so the row never shows above an empty picker. const hasFilterableBankAccount = Object.values(bankAccountList ?? {}).some(isFilterableBankAccount); const shouldDisplayBankAccountFilter = shouldDisplayFilter(hasFilterableBankAccount ? 1 : 0, true); From 8d783d22d0a556f1a07a7104676bc3d345795592 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:30:47 +0530 Subject: [PATCH 074/485] drop noop fallback for debounced search callback --- src/components/Search/FilterComponents/MultiSelect.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 07f6db40e392..b43c57027f2a 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -24,9 +24,6 @@ import {View} from 'react-native'; import ListFilterView from './ListFilterViewWrapper'; -// Stable fallback so the debounced search callback keeps its identity when no search handler is provided -function noop() {} - type MultiSelectItem = { text: string; value: T; @@ -93,7 +90,10 @@ function MultiSelect({ const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); // Server-side search is fired from the text change handler so typing and the API call stay in the same event path - const debouncedOnSearchChange = useDebounce<(term: string) => void>(onSearchChange ?? noop, CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + const searchCallback = (term: string) => { + onSearchChange?.(term); + }; + const debouncedOnSearchChange = useDebounce<(term: string) => void>(searchCallback, CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); const updateSearchTerm = (text: string) => { setSearchTerm(text); From a01882f53b58203f3068bc4a0c7db0ddccfd550a Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:30:48 +0530 Subject: [PATCH 075/485] remove redundant useCallback and rename search to searchTags --- .../Search/FilterComponents/TagSelector.tsx | 4 ++-- src/hooks/useSearchTagFilters.ts | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index b714280f641d..4e0b3e51fdcf 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -15,7 +15,7 @@ type TagSelectorProps = SearchFilterCommonProps; function TagSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); - const {searchResults, isLoading, hasMore, loadMore, search, isInitialLoading} = useSearchTagFilters(); + const {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading} = useSearchTagFilters(); const tagItems = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; const seenTagNames = new Set(); @@ -49,7 +49,7 @@ function TagSelector({value = [], selectionListTextInputStyle, selectionListStyl footer={footer} onChange={(tags) => onChange(tags.map((tag) => tag.value))} onEndReached={hasMore ? loadMore : undefined} - onSearchChange={search} + onSearchChange={searchTags} loading={isInitialLoading} isLoadingMore={isLoading && tagItems.length > 1} /> diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index ccd50c0b75a0..935eeb2c1871 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -7,7 +7,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import useOnyx from './useOnyx'; @@ -24,8 +24,8 @@ type UseSearchTagFiltersResult = { /** Load the next page of results */ loadMore: () => void; - /** Start a new search with the given query */ - search: (query: string) => void; + /** Start a new tag search with the given query */ + searchTags: (query: string) => void; /** Whether the first fetch is still in flight with no results to show yet */ isInitialLoading: boolean; @@ -70,7 +70,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { // so the search input keeps focus while the user types, even when the latest results are empty. const [hasCompletedSearch, setHasCompletedSearch] = useState(hasCachedData); - const loadMore = useCallback(() => { + const loadMore = () => { const {hasMore: currentHasMore, nextCursor: currentCursor, searchQuery: currentQuery, isLoading: currentIsLoading} = stateRef.current; if (currentIsLoading || !currentHasMore) { return; @@ -88,9 +88,9 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { } setIsLoading(false); }); - }, []); + }; - const search = useCallback((query: string) => { + const searchTags = (query: string) => { const {hasCachedData: currentHasCachedData} = stateRef.current; const requestSeq = ++requestSeqRef.current; @@ -113,16 +113,16 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { setHasCompletedSearch(true); setIsLoading(false); }); - }, []); + }; // Fetch the first page on mount; cached results are shown immediately and refreshed in the background useEffect(() => { - search(''); - }, [search]); + searchTags(''); + }, [searchTags]); const isInitialLoading = isLoading && !hasCompletedSearch; - return {searchResults, isLoading, hasMore, loadMore, search, isInitialLoading}; + return {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading}; } export default useSearchTagFilters; From 71ee4be3e4cedc5352e4eac4f39e851ab559adc6 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:01:21 +0530 Subject: [PATCH 076/485] Type OpenSearchTagFiltersPage response locally instead of on shared Response type --- src/libs/actions/Search.ts | 20 ++++++++++++++++---- src/types/onyx/Response.ts | 6 ------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 2e1dce3ccf3f..79073ecf4500 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1046,6 +1046,14 @@ function openSearchCategoryFiltersPage() { read(READ_COMMANDS.OPEN_SEARCH_CATEGORY_FILTERS_PAGE, null, {optimisticData, successData, finallyData}); } +type OpenSearchTagFiltersPageResponse = { + /** Whether more pages of tag filter results are available */ + hasMore?: boolean; + + /** Pagination cursor to pass for fetching the next page of tag filters */ + nextCursor?: string; +}; + /** * Fetches a page of tag filter search results from the server. * Returns pagination metadata (hasMore, nextCursor) for infinite scroll. @@ -1055,10 +1063,14 @@ function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams, should if (shouldCancelPendingRequests) { HttpUtils.cancelPendingRequests(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE); } - return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}).then((response) => ({ - hasMore: !!response?.hasMore, - nextCursor: response?.nextCursor ?? '', - })); + return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}).then((response) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OpenSearchTagFiltersPage response fields are command-specific and not declared on the shared Response type + const tagFiltersResponse = response as OpenSearchTagFiltersPageResponse | undefined; + return { + hasMore: !!tagFiltersResponse?.hasMore, + nextCursor: tagFiltersResponse?.nextCursor ?? '', + }; + }); } /** diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 2f136b772b33..ab831c9d88a5 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -129,12 +129,6 @@ type Response = { /** Cache key returned from GetExpensifyCardStatementPDF */ statementKey?: string; - - /** Whether there are more pages of tag filter results */ - hasMore?: boolean; - - /** Cursor for the next page of tag filter results */ - nextCursor?: string; }; export default Response; From 4f5678beccc49c909930958339fc6bb67e749bf8 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Tue, 1 Sep 2026 02:33:41 +0000 Subject: [PATCH 077/485] Correct the Spend skeleton grace window comment --- src/CONST/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index fbe805bba112..517a454a588d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7761,9 +7761,10 @@ const CONST = { ANIMATION: { FADE_DURATION: 200, - // How long the results area stays blank after the outgoing results fade out, waiting for the incoming ones. - // A query that resolves inside this window fades straight in; a slower one reveals the skeleton instead. - // Skipped when the incoming results are already in memory, so a cached swap has no gap at all. + // Extra time the results area stays blank when the incoming query has no results yet. The skeleton renders + // immediately but the area is held at zero opacity, so a query that resolves inside this window reveals its + // results and never shows a skeleton; a slower one reveals the skeleton and swaps to results after. + // Added only in that case — a swap whose results are already in memory keeps its shorter delay. SKELETON_GRACE_DURATION: 200, }, TODO_BADGE_MAX_COUNT: 50, From e530c379f5feb16e02b2eff800d4c5d650226617 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Tue, 1 Sep 2026 02:50:36 +0000 Subject: [PATCH 078/485] Remove the Spend and tab bar haptics Reverts the haptic feedback added earlier in this branch: the four Spend query entry points and all five navigation tabs. The touched files now match main exactly. --- .../NavigationTabBar/InboxTabButton.tsx | 3 --- .../NavigationTabBar/SearchTabButton.tsx | 2 -- .../NavigationTabBar/WorkspacesTabButton.tsx | 15 ++------------- .../Navigation/NavigationTabBar/index.tsx | 3 --- .../Search/hooks/useUpdateFilterQuery.tsx | 2 -- src/libs/SearchNavigationUtils.ts | 2 -- src/pages/Search/SavedSearchList.tsx | 2 -- src/pages/Search/SearchTypeMenuNarrow.tsx | 2 -- 8 files changed, 2 insertions(+), 29 deletions(-) diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index 8db47b2a81a6..b35767100788 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -8,7 +8,6 @@ import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import HapticFeedback from '@libs/HapticFeedback'; import Navigation, {startOpenReportSpan} from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {isDeletedAction} from '@libs/ReportActionsUtils'; @@ -107,7 +106,6 @@ function WideInboxTabButton({selectedTab, statusIndicatorColor, accessibilityLab return; } - HapticFeedback.press(); startNavigateToInboxTabSpan({isWideLayout: true}); if (doesLastReportExist) { @@ -227,7 +225,6 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { return; } - HapticFeedback.press(); startNavigateToInboxTabSpan({isWideLayout: false}); Navigation.navigate(ROUTES.INBOX); }; diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 4280f4b1ef36..3cf63c6ea929 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -6,7 +6,6 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; -import HapticFeedback from '@libs/HapticFeedback'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import {startSpan} from '@libs/telemetry/activeSpans'; @@ -41,7 +40,6 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { if (selectedTab === NAVIGATION_TABS.SEARCH) { return; } - HapticFeedback.press(); clearSelectedText(); interceptAnonymousUser(() => { startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { diff --git a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx index 9ad4de6cc8b2..f996c54f0522 100644 --- a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx @@ -6,8 +6,6 @@ import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnN import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; -import HapticFeedback from '@libs/HapticFeedback'; - import CONST from '@src/CONST'; import type {ValueOf} from 'type-fest'; @@ -30,22 +28,13 @@ function WorkspacesTabButton({selectedTab, isWideLayout}: WorkspacesTabButtonPro const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); - // Pressing the tab you're already on restores the last workspace route rather than moving tabs, so it gets no - // haptic. The other tabs get the same treatment through their own early returns. - const handleWorkspacesPress = () => { - if (selectedTab !== NAVIGATION_TABS.WORKSPACES) { - HapticFeedback.press(); - } - navigateToWorkspaces(); - }; - const workspacesAccessibilityState = {selected: selectedTab === NAVIGATION_TABS.WORKSPACES}; const workspacesStatusIndicatorColor = workspacesTabIndicatorStatus ? workspacesTabIndicatorColor : undefined; if (isWideLayout) { return ( { Navigation.navigate(ROUTES.SETTINGS); }); diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 6725458e1897..1a1e171dc90d 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -3,7 +3,6 @@ import type {SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import HapticFeedback from '@libs/HapticFeedback'; import Navigation from '@libs/Navigation/Navigation'; import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; @@ -50,7 +49,6 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { return; } - HapticFeedback.press(); Navigation.setParams({q: queryString, rawQuery: undefined}); } diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index ec8aa8a3989c..925faf6c3590 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -1,11 +1,9 @@ import ROUTES from '@src/ROUTES'; import {setSearchContext} from './actions/Search'; -import HapticFeedback from './HapticFeedback'; import Navigation from './Navigation/Navigation'; function navigateToCannedSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { - HapticFeedback.press(); clearSelectedTransactions(); setSearchContext(false); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index c2d6c8781cfd..a3c5e060fb56 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -14,7 +14,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; -import HapticFeedback from '@libs/HapticFeedback'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; @@ -62,7 +61,6 @@ function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMen role: CONST.ROLE.TAB, sentryLabel: CONST.SENTRY_LABEL.SEARCH.SAVED_SEARCH_MENU_ITEM, onPress: () => { - HapticFeedback.press(); setSearchContext(false); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); }, diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 77ac438b550e..5dfe69dbbc7e 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -21,7 +21,6 @@ import useTodoCounts from '@hooks/useTodoCounts'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; -import HapticFeedback from '@libs/HapticFeedback'; import {getAllTaxRates} from '@libs/PolicyUtils'; import {getItemBadgeText, getOverflowMenu, SAVED_SEARCH_FALLBACK_ICON_NAME, SAVED_SEARCH_ICON_NAMES, SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; @@ -222,7 +221,6 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps if (!searchData) { return; } - HapticFeedback.press(); onTabPress?.(); setSearchContext(false); navigation.dispatch({ From 4efe6a58b8fed26b47d14f21c79de328d9d98c19 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Tue, 1 Sep 2026 02:58:31 +0000 Subject: [PATCH 079/485] Tighten the Spend fade comments --- src/CONST/index.ts | 6 ++---- src/pages/Search/SearchPageNarrow/index.tsx | 6 ++---- src/pages/Search/SearchPageWide.tsx | 6 ++---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9a2f7a962513..83f45c7bb5cb 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7804,10 +7804,8 @@ const CONST = { ANIMATION: { FADE_DURATION: 200, - // Extra time the results area stays blank when the incoming query has no results yet. The skeleton renders - // immediately but the area is held at zero opacity, so a query that resolves inside this window reveals its - // results and never shows a skeleton; a slower one reveals the skeleton and swaps to results after. - // Added only in that case — a swap whose results are already in memory keeps its shorter delay. + // Extra time the results area is held at zero opacity when the incoming query has no results yet, so a + // query resolving inside it never shows a skeleton. Not added when the results are already in memory. SKELETON_GRACE_DURATION: 200, }, TODO_BADGE_MAX_COUNT: 50, diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 4cef5535db66..1b3112ad68fa 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -337,10 +337,8 @@ function SearchPageNarrow({ <> {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A query change remounts this layer: the outgoing one fades out, and the incoming one stays hidden until - that finishes. When the new results aren't in memory yet it waits a further grace window, so a query - that resolves quickly fades straight to its results rather than through a skeleton. Both layers are - absolutely filled so the outgoing fade overlays the incoming one instead of sharing the column layout. */} + {/* A query change remounts this layer, which stays hidden for the delay before fading in — plus a grace + window when its results aren't in memory, so a quick query never shows a skeleton. */} {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A query change remounts this layer: the outgoing one fades out, and the incoming one stays hidden until - that finishes. When the new results aren't in memory yet it waits a further grace window, so a query - that resolves quickly fades straight to its results rather than through a skeleton. Both layers are - absolutely filled so the outgoing fade overlays the incoming one instead of sharing the column layout. */} + {/* A query change remounts this layer, which stays hidden for the delay before fading in — plus a grace + window when its results aren't in memory, so a quick query never shows a skeleton. */} Date: Tue, 1 Sep 2026 03:54:04 +0000 Subject: [PATCH 080/485] Reduce fade duration to 120 --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 83f45c7bb5cb..797f7ca00f67 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7802,7 +7802,7 @@ const CONST = { SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', ANIMATION: { - FADE_DURATION: 200, + FADE_DURATION: 120, // Extra time the results area is held at zero opacity when the incoming query has no results yet, so a // query resolving inside it never shows a skeleton. Not added when the results are already in memory. From 878059c8865579176ffafbe7e9337a4f0b43da64 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Tue, 1 Sep 2026 04:13:17 +0000 Subject: [PATCH 081/485] Show the Spend skeleton sooner on a slow query The grace window kept the results area hidden for FADE_DURATION plus the window, so a slow query waited 400ms before its skeleton appeared. The delay is flat now, and FADE_DURATION drops to 120, so the skeleton shows in 120ms while a query resolving inside that window still swaps straight to results. SKELETON_GRACE_DURATION had no consumer left and is removed. --- src/CONST/index.ts | 4 ---- src/pages/Search/SearchPageNarrow/index.tsx | 10 +++------- src/pages/Search/SearchPageWide.tsx | 10 +++------- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 797f7ca00f67..bc72c1759238 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7803,10 +7803,6 @@ const CONST = { GROUP_PREFIX: 'group_', ANIMATION: { FADE_DURATION: 120, - - // Extra time the results area is held at zero opacity when the incoming query has no results yet, so a - // query resolving inside it never shows a skeleton. Not added when the results are already in memory. - SKELETON_GRACE_DURATION: 200, }, TODO_BADGE_MAX_COUNT: 50, TOP_SEARCH_LIMIT: 10, diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 1b3112ad68fa..08014597cf0b 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -337,15 +337,11 @@ function SearchPageNarrow({ <> {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A query change remounts this layer, which stays hidden for the delay before fading in — plus a grace - window when its results aren't in memory, so a quick query never shows a skeleton. */} + {/* A query change remounts this layer, which stays hidden for the delay before fading in. A query that + resolves inside that window swaps straight to its results without showing a skeleton. */} diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index f561a7bcfeaf..720b9d423fae 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -125,15 +125,11 @@ function SearchPageWide({ {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A query change remounts this layer, which stays hidden for the delay before fading in — plus a grace - window when its results aren't in memory, so a quick query never shows a skeleton. */} + {/* A query change remounts this layer, which stays hidden for the delay before fading in. A query that + resolves inside that window swaps straight to its results without showing a skeleton. */} From 07791eb86f87970c69906ab34cac0645fba96386 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Tue, 1 Sep 2026 04:24:17 +0000 Subject: [PATCH 082/485] Stop fading the outgoing Spend results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reanimated fades an exiting element on web by detaching its DOM node and re-inserting it through a mutation observer. React still expects the node where it left it, so unmounting the tree mid-animation threw NotFoundError from commitDeletionEffectsOnFiber — reproducible by cold starting and navigating straight to Spend. The same detaching left a chart's Skia canvas without a drawing surface, and it never produced a visible fade anyway, since the results list tears its rows down as it unmounts. SearchLoadingSkeleton and SearchWithNavigationDeferredMount go back to their main versions, dropping the absolutely filled wrapper that only existed to overlay the outgoing fade. --- .../Search/SearchLoadingSkeleton.tsx | 8 +++----- .../SearchWithNavigationDeferredMount.tsx | 19 +++++-------------- src/pages/Search/SearchPageNarrow/index.tsx | 3 +-- src/pages/Search/SearchPageWide.tsx | 3 +-- 4 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index b27631a860bd..77908ab0607c 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -10,8 +10,7 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; -import {StyleSheet} from 'react-native'; -import Animated, {FadeOut} from 'react-native-reanimated'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; type SearchLoadingSkeletonProps = { containerStyle?: StyleProp; @@ -21,11 +20,10 @@ function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { const styles = useThemeStyles(); return ( - // The skeleton is absolutely filled so that its exit fade overlays the incoming results instead of - // sharing the parent's column layout with them, which would halve both heights for the fade duration. { endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.COLD); diff --git a/src/components/Search/SearchWithNavigationDeferredMount.tsx b/src/components/Search/SearchWithNavigationDeferredMount.tsx index 76eb379e3863..b265db02c924 100644 --- a/src/components/Search/SearchWithNavigationDeferredMount.tsx +++ b/src/components/Search/SearchWithNavigationDeferredMount.tsx @@ -13,8 +13,6 @@ import CONST from '@src/CONST'; import type {ComponentProps} from 'react'; import React from 'react'; -import {StyleSheet} from 'react-native'; -import Animated, {FadeOut} from 'react-native-reanimated'; import Search from './index'; @@ -41,18 +39,11 @@ function SearchWithNavigationDeferredMount(props: ComponentProps) - - + } > diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 08014597cf0b..467cedf90b47 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -43,7 +43,7 @@ import type {SearchResults} from '@src/types/onyx'; import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useRef, useState, useTransition} from 'react'; import {StyleSheet, View} from 'react-native'; -import Animated, {clamp, FadeIn, FadeOut, LayoutAnimationConfig, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {clamp, FadeIn, LayoutAnimationConfig, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import {SearchActionsBarSwitch, SearchFiltersBarSwitch, SearchPageInputSwitch, SearchTypeMenuSwitch} from './Switches'; @@ -342,7 +342,6 @@ function SearchPageNarrow({ {shouldShowLoadingSkeleton ? ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 720b9d423fae..f55ab793ab60 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -32,7 +32,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import React, {useCallback, useContext, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; -import Animated, {FadeIn, FadeOut, LayoutAnimationConfig} from 'react-native-reanimated'; +import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'; type SearchPageWideProps = { queryJSON?: SearchQueryJSON; @@ -130,7 +130,6 @@ function SearchPageWide({ {shouldShowLoadingSkeleton ? ( From 186db337d0fecfa73b10616ce955f2a0cc13cb4f Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 1 Sep 2026 14:58:37 +0200 Subject: [PATCH 083/485] Extract useMoneyRequestReportScroll --- .../MoneyRequestReportActionsList.tsx | 257 ++------------ .../useMoneyRequestReportScroll.ts | 322 ++++++++++++++++++ 2 files changed, 351 insertions(+), 228 deletions(-) create mode 100644 src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 357c268f4f72..4a21f5fd7979 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,4 +1,3 @@ -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; @@ -8,10 +7,8 @@ import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportScrollManager from '@hooks/useReportScrollManager'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; @@ -21,7 +18,6 @@ import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, hasNextActionMadeBySameActor} from '@libs/ReportActionsUtils'; @@ -29,15 +25,11 @@ import {canUserPerformWriteAction, chatIncludesChronosWithID, getReportLastVisib import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; -import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; -import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; +import {useActionListRef} from '@pages/inbox/ActionListContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; -import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; - -import {openReport, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -46,7 +38,7 @@ import {getStableReportSelector} from '@src/selectors/Report'; import {pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; import {useIsFocused, useRoute} from '@react-navigation/native'; import isEmpty from 'lodash/isEmpty'; @@ -57,6 +49,7 @@ import MoneyRequestReportEmptyStateView from './MoneyRequestReportEmptyStateView import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; import SelectionToolbar from './SelectionToolbar'; import useMoneyRequestReportPagination from './useMoneyRequestReportPagination'; +import useMoneyRequestReportScroll from './useMoneyRequestReportScroll'; import useMoneyRequestReportVisibleActions from './useMoneyRequestReportVisibleActions'; /** @@ -64,9 +57,6 @@ import useMoneyRequestReportVisibleActions from './useMoneyRequestReportVisibleA */ const EmptyParentReportActionForTransactionThread = undefined; -// Amount of time to wait until all list items should be rendered and scrollToEnd will behave well -const DELAY_FOR_SCROLLING_TO_END = 100; - type MoneyRequestReportListProps = { /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; @@ -79,33 +69,14 @@ type MoneyRequestReportActionsListContentProps = MoneyRequestReportListProps & { /** * Renders the money-request report's unified list (transactions table + report actions). Composes the - * view's data/behavior hooks (`useMoneyRequestReportVisibleActions` / `useMoneyRequestReportPagination`) - * with the hooks shared with the chat list (`useUnreadMarker` / `useMarkAsRead`). + * view's data/behavior hooks (`useMoneyRequestReportVisibleActions` / `useMoneyRequestReportPagination` / + * `useMoneyRequestReportScroll`) with the hooks shared with the chat list (`useUnreadMarker` / `useMarkAsRead`). * Mounted with `key={reportID}` by the wrapper below, so all hook state resets on report switch. */ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: MoneyRequestReportActionsListContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const reportScrollManager = useReportScrollManager(); - // The unified list writes its last item index here (see lastItemIndexRef prop). We jump to the bottom via - // scrollToIndex rather than scrollToEnd: scrollToEnd targets an estimated content-end offset, which on a large - // list (hundreds of transactions + chat) leaves the bottom blank until it renders/corrects. scrollToIndex - // targets the last item directly and renders around it, so the landing is not blank. - const lastItemIndexRef = useRef(0); - const updateLastItemIndex = useCallback((index: number) => { - lastItemIndexRef.current = index; - }, []); - - const scrollToBottom = useCallback(() => { - if (lastItemIndexRef.current < 0) { - return; - } - - reportScrollManager.scrollToIndex(lastItemIndexRef.current, {animated: false, viewPosition: 1}); - }, [reportScrollManager]); - - const didLayout = useRef(false); const isFocused = useIsFocused(); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); // The table is visible whenever it's wide, or — on narrow — only when focused (the RHP has closed). @@ -154,21 +125,15 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const parentReportAction = useParentReportAction(report); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], false, reportTransactionIDs); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); - const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(reportID)}`); const shouldShowHarvestCreatedAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - const [enableScrollToEnd, setEnableScrollToEnd] = useState(false); - const [lastActionEventId, setLastActionEventId] = useState(''); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const {visibleReportActions, visibleReportActionsNewestFirst, lastAction, firstVisibleReportActionID} = useMoneyRequestReportVisibleActions({ @@ -180,14 +145,9 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon isOffline, }); - const {scrollOffsetRef} = useActionListContext(); const listRef = useActionListRef(); + const didLayout = useRef(false); - const scrollingVerticalBottomOffset = useRef(0); - const stickToBottomRef = useRef(false); - const stickToBottomTimeoutRef = useRef(null); - // Set when the user taps "Latest messages"; the report is marked as read only once the scroll actually reaches the bottom. - const pendingMarkAsReadRef = useRef(false); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; @@ -229,129 +189,33 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon scopeKey: 'moneyRequestReport', }); - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ - reportID: reportID ?? reportIDFromRoute ?? '', - currentVerticalScrollingOffsetRef: scrollingVerticalBottomOffset, - onUnreadActionVisible: completeSkippedMarkAsRead, - unreadMarkerReportActionIndex, - isInverted: false, - hasNewerActions, - onTrackScrolling: (event: NativeSyntheticEvent) => { - const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; - const fullContentHeight = contentSize.height; - - /** - * Count the diff between current scroll position and the bottom of the list. - * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) - */ - scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; - scrollOffsetRef.current = scrollingVerticalBottomOffset.current; - setHasScrolledOverThreshold(scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - // Mark the report as read only once the scroll has actually reached the bottom. The jump fired by - // "Latest messages" settles over several frames as deferred items hydrate, so we wait for the real end. - if (pendingMarkAsReadRef.current && scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { - pendingMarkAsReadRef.current = false; - markNewestActionAsRead(); - } - }, - }); - - useScrollToEndOnNewMessageReceived({ - sizeChangeType: 'grewFromReportActions', - scrollOffsetRef, - lastActionID: lastAction?.reportActionID, - visibleActionsLength: visibleReportActions.length, - reportActionsLength: reportActions.length, - hasNewestReportAction, - setIsFloatingMessageCounterVisible, - scrollToEnd: scrollToBottom, - resetKey: reportID ?? reportIDFromRoute ?? '', - }); - - // The indicator renders in the list footer, below the row scrollToBottom targets, so only - // scrollToEnd reveals it. This list is not inverted, so nothing sticks to the bottom for us. - const {candidateAgentIDs} = useAgentZeroStatus(); - const isThinkingIndicatorVisible = candidateAgentIDs.length > 0; - // Scroll once per appearance: the label changes many times per run, and re-firing would yank - // the viewport away from a user who has since scrolled up. - const hasScrolledForThinkingIndicatorRef = useRef(false); - useEffect(() => { - if (!isThinkingIndicatorVisible) { - hasScrolledForThinkingIndicatorRef.current = false; - return; - } - if (hasScrolledForThinkingIndicatorRef.current || scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { - return; - } - hasScrolledForThinkingIndicatorRef.current = true; - - // Wait for the footer to lay out, otherwise the content hasn't grown yet and there is - // nothing to scroll to. - const timeoutID = setTimeout(() => { - reportScrollManager.scrollToEnd(); - }, DELAY_FOR_SCROLLING_TO_END); - - return () => clearTimeout(timeoutID); - }, [isThinkingIndicatorVisible, reportScrollManager]); - - const scrollToBottomForCurrentUserAction = useCallback( - (isFromCurrentUser: boolean, reportAction?: OnyxTypes.ReportAction) => { - TransitionTracker.runAfterTransitions({ - callback: () => { - setIsFloatingMessageCounterVisible(false); - // If a new comment is added from the current user, scroll to the bottom, otherwise leave the user position unchanged - if (!isFromCurrentUser || reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) { - return; - } - - // We want to scroll to the end of the list where the newest message is. We route through the indexed - // scrollToBottom (scrollToIndex) rather than scrollToEnd because scrollToEnd targets an estimated - // content-end offset that leaves the bottom blank on large transaction+chat lists. We still delay so - // the just-sent item has landed in the data before we jump. - const index = visibleReportActions.findIndex((item) => item.reportActionID === reportAction?.reportActionID); - if (index !== -1) { - setTimeout(() => { - scrollToBottom(); - }, DELAY_FOR_SCROLLING_TO_END); - } else { - setEnableScrollToEnd(true); - setLastActionEventId(reportAction?.reportActionID); - } - }, - }); - }, - [scrollToBottom, setIsFloatingMessageCounterVisible, visibleReportActions], - ); + const {isFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged, scrollToLatestMessages, onListContentSizeChange, onListScrollBeginDrag, updateLastItemIndex} = + useMoneyRequestReportScroll({ + reportID, + resetKey: reportID ?? reportIDFromRoute ?? '', + visibleReportActions, + reportActionsLength: reportActions.length, + lastAction, + hasNewestReportAction, + hasNewerActions, + unreadMarkerReportActionIndex, + onScrolledOverThresholdChange: setHasScrolledOverThreshold, + markNewestActionAsRead, + completeSkippedMarkAsRead, + }); + // When the report is opened from the "X Replies" link, scroll to the latest message once the actions are + // available (this list otherwise opens at the top). scrollToLatestMessages pins to the bottom while the + // deferred content settles, mirroring the floating "new messages" button. We clear the route param afterwards + // so a later re-render or remount doesn't yank the user back down. useEffect(() => { - if (!report?.reportID) { + if (!shouldScrollToLatestOnOpen || scrolledToLatestOnOpenForReportIDRef.current === reportIDFromRoute || visibleReportActions.length === 0) { return; } - // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.ts. This allows us to maintain - // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props. - const unsubscribe = subscribeToNewActionEvent(report.reportID, scrollToBottomForCurrentUserAction); - - return () => { - if (!unsubscribe) { - return; - } - unsubscribe(); - }; - - // This effect handles subscribing to events, so we only want to run it on mount, and in case reportID changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [report?.reportID]); - - useEffect(() => { - const index = visibleReportActions.findIndex((item) => item.reportActionID === lastActionEventId); - if (enableScrollToEnd && index !== -1) { - setTimeout(() => { - scrollToBottom(); - }, DELAY_FOR_SCROLLING_TO_END); - setEnableScrollToEnd(false); - } - }, [visibleReportActions, lastActionEventId, enableScrollToEnd, scrollToBottom]); + scrolledToLatestOnOpenForReportIDRef.current = reportIDFromRoute; + scrollToLatestMessages(); + Navigation.setParams({[REPORT_LINK_ROUTE_PARAMS.SHOULD_SCROLL_TO_LATEST]: undefined}); + }, [shouldScrollToLatestOnOpen, visibleReportActions.length, scrollToLatestMessages, reportIDFromRoute]); const renderReportAction = useCallback( (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => { @@ -399,69 +263,6 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const reportActionsExtraData = useMemo(() => [draftReportActionID, isDraftPendingCompletion], [draftReportActionID, isDraftPendingCompletion]); - const scrollToLatestMessages = useCallback(() => { - setIsFloatingMessageCounterVisible(false); - - stickToBottomRef.current = true; - if (stickToBottomTimeoutRef.current) { - clearTimeout(stickToBottomTimeoutRef.current); - } - // Safety net: stop pinning after deferred content has had time to settle, so a much later - // unrelated layout change doesn't yank the user back down. - stickToBottomTimeoutRef.current = setTimeout(() => { - stickToBottomRef.current = false; - }, 2000); - - if (!hasNewestReportAction) { - openReport({reportID, introSelected, conciergeChat, betas, hasReportActions: true, currentUserAccountID}); - scrollToBottom(); - return; - } - - // Defer marking the report as read until the scroll actually reaches the bottom (handled in onTrackScrolling). - pendingMarkAsReadRef.current = true; - scrollToBottom(); - }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, scrollToBottom, reportID, introSelected, conciergeChat, betas, currentUserAccountID]); - - useEffect(() => { - return () => { - if (!stickToBottomTimeoutRef.current) { - return; - } - clearTimeout(stickToBottomTimeoutRef.current); - }; - }, []); - - // When the report is opened from the "X Replies" link, scroll to the latest message once the actions are - // available (this list otherwise opens at the top). scrollToLatestMessages pins to the bottom while the - // deferred content settles, mirroring the floating "new messages" button. We clear the route param afterwards - // so a later re-render or remount doesn't yank the user back down. - useEffect(() => { - if (!shouldScrollToLatestOnOpen || scrolledToLatestOnOpenForReportIDRef.current === reportIDFromRoute || visibleReportActions.length === 0) { - return; - } - scrolledToLatestOnOpenForReportIDRef.current = reportIDFromRoute; - scrollToLatestMessages(); - Navigation.setParams({[REPORT_LINK_ROUTE_PARAMS.SHOULD_SCROLL_TO_LATEST]: undefined}); - }, [shouldScrollToLatestOnOpen, visibleReportActions.length, scrollToLatestMessages, reportIDFromRoute]); - - const onListContentSizeChange = () => { - if (!stickToBottomRef.current) { - return; - } - scrollToBottom(); - }; - - const onListScrollBeginDrag = () => { - stickToBottomRef.current = false; - // The user scrolled away before reaching the bottom, so cancel the pending read. - pendingMarkAsReadRef.current = false; - if (stickToBottomTimeoutRef.current) { - clearTimeout(stickToBottomTimeoutRef.current); - stickToBottomTimeoutRef.current = null; - } - }; - /** * Runs when the FlatList finishes laying out */ diff --git a/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts new file mode 100644 index 000000000000..575e8851f292 --- /dev/null +++ b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts @@ -0,0 +1,322 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import useReportScrollManager from '@hooks/useReportScrollManager'; +import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; + +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import {useActionListContext} from '@pages/inbox/ActionListContext'; +import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; +import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; + +import {openReport, subscribeToNewActionEvent} from '@userActions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; + +import {useEffect, useEffectEvent, useRef, useState} from 'react'; + +// Amount of time to wait until all list items should be rendered and scrollToEnd will behave well +const DELAY_FOR_SCROLLING_TO_END = 100; + +type UseMoneyRequestReportScrollParams = { + /** The report whose list is being scrolled */ + reportID: string | undefined; + + /** Key that resets the new-message autoscroll tracking, usually the reportID */ + resetKey: string; + + /** Actions rendered in the unified list, oldest-first */ + visibleReportActions: OnyxTypes.ReportAction[]; + + /** Number of paginated report actions (pre-filter) */ + reportActionsLength: number; + + /** The newest visible action */ + lastAction: OnyxTypes.ReportAction | undefined; + + /** Whether the newest report action is already loaded */ + hasNewestReportAction: boolean; + + /** Whether newer actions exist beyond the loaded page */ + hasNewerActions: boolean; + + /** Index of the unread marker within the rendered (oldest-first) actions, or -1 */ + unreadMarkerReportActionIndex: number; + + /** Reports whether the list is scrolled further from the bottom than the action-visible threshold */ + onScrolledOverThresholdChange: (isScrolledOverThreshold: boolean) => void; + + /** Marks the newest action as read (from `useMarkAsRead`) */ + markNewestActionAsRead: () => void; + + /** Completes a previously skipped mark-as-read (from `useMarkAsRead`) */ + completeSkippedMarkAsRead: () => void; +}; + +type UseMoneyRequestReportScrollResult = { + /** Whether the "Latest messages" pill should show */ + isFloatingMessageCounterVisible: boolean; + + /** FlashList onScroll handler */ + trackVerticalScrolling: (event: NativeSyntheticEvent | undefined) => void; + + /** FlashList onViewableItemsChanged handler */ + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + + /** "Latest messages" pill click handler — scrolls to the bottom and marks the report as read once it lands */ + scrollToLatestMessages: () => void; + + /** FlashList onContentSizeChange handler — keeps the list pinned to the bottom while stick-to-bottom is active */ + onListContentSizeChange: () => void; + + /** FlashList onScrollBeginDrag handler — cancels stick-to-bottom and any pending mark-as-read */ + onListScrollBeginDrag: () => void; + + /** Receives the unified list's last item index so scroll-to-bottom can target it via scrollToIndex */ + updateLastItemIndex: (index: number) => void; +}; + +/** + * Owns the scroll behavior of the money-request report view's unified list: bottom-offset tracking, + * the "Latest messages" pill, scroll-to-bottom on own/new messages, stick-to-bottom while deferred + * content settles, and the AgentZero thinking-indicator reveal. + * + * Unlike the chat list this list is NOT inverted (data is oldest-first) — the scroll model is + * bottom-offset math over a normal list, and jumping to the newest message goes through + * scrollToIndex on the last item rather than scrollToEnd (see `scrollToBottom`). + */ +function useMoneyRequestReportScroll({ + reportID, + resetKey, + visibleReportActions, + reportActionsLength, + lastAction, + hasNewestReportAction, + hasNewerActions, + unreadMarkerReportActionIndex, + onScrolledOverThresholdChange, + markNewestActionAsRead, + completeSkippedMarkAsRead, +}: UseMoneyRequestReportScrollParams): UseMoneyRequestReportScrollResult { + const reportScrollManager = useReportScrollManager(); + const {scrollOffsetRef} = useActionListContext(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + + // The unified list writes its last item index here (see updateLastItemIndex). We jump to the bottom via + // scrollToIndex rather than scrollToEnd: scrollToEnd targets an estimated content-end offset, which on a large + // list (hundreds of transactions + chat) leaves the bottom blank until it renders/corrects. scrollToIndex + // targets the last item directly and renders around it, so the landing is not blank. + const lastItemIndexRef = useRef(0); + const updateLastItemIndex = (index: number) => { + lastItemIndexRef.current = index; + }; + + const scrollToBottom = () => { + if (lastItemIndexRef.current < 0) { + return; + } + + reportScrollManager.scrollToIndex(lastItemIndexRef.current, {animated: false, viewPosition: 1}); + }; + + const scrollingVerticalBottomOffset = useRef(0); + const stickToBottomRef = useRef(false); + const stickToBottomTimeoutRef = useRef(null); + // Set when the user taps "Latest messages"; the report is marked as read only once the scroll actually reaches the bottom. + const pendingMarkAsReadRef = useRef(false); + + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ + reportID: resetKey, + currentVerticalScrollingOffsetRef: scrollingVerticalBottomOffset, + onUnreadActionVisible: completeSkippedMarkAsRead, + unreadMarkerReportActionIndex, + isInverted: false, + hasNewerActions, + onTrackScrolling: (event: NativeSyntheticEvent) => { + const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; + const fullContentHeight = contentSize.height; + + /** + * Count the diff between current scroll position and the bottom of the list. + * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) + */ + scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; + scrollOffsetRef.current = scrollingVerticalBottomOffset.current; + onScrolledOverThresholdChange(scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + + // Mark the report as read only once the scroll has actually reached the bottom. The jump fired by + // "Latest messages" settles over several frames as deferred items hydrate, so we wait for the real end. + if (pendingMarkAsReadRef.current && scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + pendingMarkAsReadRef.current = false; + markNewestActionAsRead(); + } + }, + }); + + useScrollToEndOnNewMessageReceived({ + sizeChangeType: 'grewFromReportActions', + scrollOffsetRef, + lastActionID: lastAction?.reportActionID, + visibleActionsLength: visibleReportActions.length, + reportActionsLength, + hasNewestReportAction, + setIsFloatingMessageCounterVisible, + scrollToEnd: scrollToBottom, + resetKey, + }); + + // The indicator renders in the list footer, below the row scrollToBottom targets, so only + // scrollToEnd reveals it. This list is not inverted, so nothing sticks to the bottom for us. + const {candidateAgentIDs} = useAgentZeroStatus(); + const isThinkingIndicatorVisible = candidateAgentIDs.length > 0; + // Scroll once per appearance: the label changes many times per run, and re-firing would yank + // the viewport away from a user who has since scrolled up. + const hasScrolledForThinkingIndicatorRef = useRef(false); + useEffect(() => { + if (!isThinkingIndicatorVisible) { + hasScrolledForThinkingIndicatorRef.current = false; + return; + } + if (hasScrolledForThinkingIndicatorRef.current || scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + return; + } + hasScrolledForThinkingIndicatorRef.current = true; + + // Wait for the footer to lay out, otherwise the content hasn't grown yet and there is + // nothing to scroll to. + const timeoutID = setTimeout(() => { + reportScrollManager.scrollToEnd(); + }, DELAY_FOR_SCROLLING_TO_END); + + return () => clearTimeout(timeoutID); + }, [isThinkingIndicatorVisible, reportScrollManager]); + + // When the just-sent action hasn't landed in the visible data yet, remember it and scroll once it does. + const [pendingScrollToActionID, setPendingScrollToActionID] = useState(null); + + // Effect Event so the Pusher subscription below can stay subscribed once per report while still + // reading the latest visible actions. + const onNewActionEvent = useEffectEvent((isFromCurrentUser: boolean, reportAction?: OnyxTypes.ReportAction) => { + TransitionTracker.runAfterTransitions({ + callback: () => { + setIsFloatingMessageCounterVisible(false); + // If a new comment is added from the current user, scroll to the bottom, otherwise leave the user position unchanged + if (!isFromCurrentUser || reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) { + return; + } + + // We want to scroll to the end of the list where the newest message is. We route through the indexed + // scrollToBottom (scrollToIndex) rather than scrollToEnd because scrollToEnd targets an estimated + // content-end offset that leaves the bottom blank on large transaction+chat lists. We still delay so + // the just-sent item has landed in the data before we jump. + const index = visibleReportActions.findIndex((item) => item.reportActionID === reportAction?.reportActionID); + if (index !== -1) { + setTimeout(() => { + scrollToBottom(); + }, DELAY_FOR_SCROLLING_TO_END); + } else { + setPendingScrollToActionID(reportAction?.reportActionID ?? null); + } + }, + }); + }); + + useEffect(() => { + if (!reportID) { + return; + } + // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.ts. This allows us to maintain + // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props. + const unsubscribe = subscribeToNewActionEvent(reportID, onNewActionEvent); + + return () => { + unsubscribe?.(); + }; + }, [reportID]); + + useEffect(() => { + if (!pendingScrollToActionID) { + return; + } + const index = visibleReportActions.findIndex((item) => item.reportActionID === pendingScrollToActionID); + if (index === -1) { + return; + } + const timeoutID = setTimeout(() => { + scrollToBottom(); + setPendingScrollToActionID(null); + }, DELAY_FOR_SCROLLING_TO_END); + + return () => clearTimeout(timeoutID); + }, [pendingScrollToActionID, visibleReportActions, scrollToBottom]); + + const scrollToLatestMessages = () => { + setIsFloatingMessageCounterVisible(false); + + stickToBottomRef.current = true; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + } + // Safety net: stop pinning after deferred content has had time to settle, so a much later + // unrelated layout change doesn't yank the user back down. + stickToBottomTimeoutRef.current = setTimeout(() => { + stickToBottomRef.current = false; + }, 2000); + + if (!hasNewestReportAction) { + openReport({reportID, introSelected, conciergeChat, betas, hasReportActions: true, currentUserAccountID}); + scrollToBottom(); + return; + } + + // Defer marking the report as read until the scroll actually reaches the bottom (handled in onTrackScrolling). + pendingMarkAsReadRef.current = true; + scrollToBottom(); + }; + + useEffect(() => { + return () => { + if (!stickToBottomTimeoutRef.current) { + return; + } + clearTimeout(stickToBottomTimeoutRef.current); + }; + }, []); + + const onListContentSizeChange = () => { + if (!stickToBottomRef.current) { + return; + } + scrollToBottom(); + }; + + const onListScrollBeginDrag = () => { + stickToBottomRef.current = false; + // The user scrolled away before reaching the bottom, so cancel the pending read. + pendingMarkAsReadRef.current = false; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + stickToBottomTimeoutRef.current = null; + } + }; + + return { + isFloatingMessageCounterVisible, + trackVerticalScrolling, + onViewableItemsChanged, + scrollToLatestMessages, + onListContentSizeChange, + onListScrollBeginDrag, + updateLastItemIndex, + }; +} + +export default useMoneyRequestReportScroll; From 320250eea8d310d0680956d90090f88bc2ae82c8 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:30:44 +0530 Subject: [PATCH 084/485] Scope the tag filter search to the selected workspaces --- .../Search/FilterComponents/TagSelector.tsx | 12 ++++++++---- src/hooks/useSearchTagFilters.ts | 7 ++++--- .../API/parameters/OpenSearchTagFiltersPageParams.ts | 3 +++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 4e0b3e51fdcf..97d51928f25c 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -1,4 +1,4 @@ -import type {SearchFilterCommonProps} from '@components/Search/types'; +import type {Filter, SearchFilterCommonProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useSearchTagFilters from '@hooks/useSearchTagFilters'; @@ -11,11 +11,15 @@ import React from 'react'; import MultiSelect from './MultiSelect'; -type TagSelectorProps = SearchFilterCommonProps; +type TagSelectorProps = SearchFilterCommonProps & { + policyID: Filter | undefined; +}; -function TagSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { +function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); - const {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading} = useSearchTagFilters(); + // A negated workspace filter cannot be expressed as an inclusion list, so it falls back to searching every workspace + const policyIDs = policyID?.isNegated ? '' : (policyID?.value ?? []).join(','); + const {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading} = useSearchTagFilters(policyIDs); const tagItems = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; const seenTagNames = new Set(); diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 935eeb2c1871..4ba54b238afb 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -43,8 +43,9 @@ function logRequestFailure(message: string, error: Error) { * Hook for managing paginated tag filter search. * Handles API calls, pagination state, and Onyx data subscription. * Pagination state is persisted in Onyx (RAM-only) to survive component remounts. + * Accepts a comma-separated list of policy IDs to scope results to specific workspaces. */ -function useSearchTagFilters(): UseSearchTagFiltersResult { +function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { const [searchResults] = useOnyx(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS); const [paginationState] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION); const [isLoading, setIsLoading] = useState(false); @@ -77,7 +78,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { } const requestSeq = requestSeqRef.current; setIsLoading(true); - openSearchTagFiltersPage({searchQuery: currentQuery, cursor: currentCursor, limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}) + openSearchTagFiltersPage({searchQuery: currentQuery, cursor: currentCursor, limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE, policyIDs}) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { setSearchTagFiltersPagination(newHasMore, newCursor, currentQuery); }) @@ -101,7 +102,7 @@ function useSearchTagFilters(): UseSearchTagFiltersResult { // With cached data the fetch runs silently in the background. setIsLoading(!currentHasCachedData); - openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}, true) + openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE, policyIDs}, true) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { setSearchTagFiltersPagination(newHasMore, newCursor, query); }) diff --git a/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts b/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts index 9b690306dff6..19eefb7d8196 100644 --- a/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts +++ b/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts @@ -8,6 +8,9 @@ type OpenSearchTagFiltersPageParams = { /** Maximum number of tags to return per page */ limit?: number; + /** Comma-separated list of policy IDs to scope the tag search to specific workspaces */ + policyIDs?: string; + /** Whether the request can be aborted when superseded by a newer search */ canCancel?: boolean; }; From efe3a65d24431d55b961cab7d87f10a3f8f14074 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:31:10 +0530 Subject: [PATCH 085/485] Hide the empty-tag option when it does not match the tag search --- .../Search/FilterComponents/TagSelector.tsx | 11 +++++++++-- src/hooks/useSearchTagFilters.ts | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 97d51928f25c..6aeaa08d4726 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -9,6 +9,8 @@ import CONST from '@src/CONST'; import React from 'react'; +import type {MultiSelectItem} from './MultiSelect/types'; + import MultiSelect from './MultiSelect'; type TagSelectorProps = SearchFilterCommonProps & { @@ -19,9 +21,14 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti const {translate} = useLocalize(); // A negated workspace filter cannot be expressed as an inclusion list, so it falls back to searching every workspace const policyIDs = policyID?.isNegated ? '' : (policyID?.value ?? []).join(','); - const {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading} = useSearchTagFilters(policyIDs); + const {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading, searchQuery} = useSearchTagFilters(policyIDs); - const tagItems = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; + const tagItems: Array> = []; + const emptyTagItem = {text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}; + // The empty-tag option is client-side only, so it is matched against the applied search query locally + if (!searchQuery || emptyTagItem.text.toLowerCase().includes(searchQuery.toLowerCase())) { + tagItems.push(emptyTagItem); + } const seenTagNames = new Set(); // Preserve backend order - new items append at end for infinite scroll diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 4ba54b238afb..45f029c34994 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -29,6 +29,9 @@ type UseSearchTagFiltersResult = { /** Whether the first fetch is still in flight with no results to show yet */ isInitialLoading: boolean; + + /** The search query of the currently displayed results */ + searchQuery: string; }; /** Logs tag filter request failures; aborted requests are expected when a newer search supersedes them */ @@ -123,7 +126,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { const isInitialLoading = isLoading && !hasCompletedSearch; - return {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading}; + return {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading, searchQuery}; } export default useSearchTagFilters; From 2e3ec4ebf87d40852ffc42a094015f1f49009546 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:31:25 +0530 Subject: [PATCH 086/485] Keep selected tags visible when they are not in the current result page --- src/components/Search/FilterComponents/TagSelector.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 6aeaa08d4726..f97cf35363ed 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -49,6 +49,16 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti return {text: getCleanedTagName(tag), value: tag}; }); + // Selected tags that are not in the current result page stay visible once the search is cleared, so the selection doesn't disappear from the list + if (!searchQuery) { + const itemValues = new Set(tagItems.map((item) => item.value)); + for (const selectedItem of selectedTagsItems) { + if (!itemValues.has(selectedItem.value)) { + tagItems.push(selectedItem); + } + } + } + return ( Date: Tue, 1 Sep 2026 20:33:18 +0530 Subject: [PATCH 087/485] Fix the MultiSelectItem import path --- src/components/Search/FilterComponents/TagSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index f97cf35363ed..048e350f4fca 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import React from 'react'; -import type {MultiSelectItem} from './MultiSelect/types'; +import type {MultiSelectItem} from './MultiSelect'; import MultiSelect from './MultiSelect'; From 3a0c637da365db8e640ba323988e4351b00a2be7 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 1 Sep 2026 11:34:10 -0700 Subject: [PATCH 088/485] Rewrite React Compiler HIR syntax blockers Hoist default-arg expressions and rewrite ??= / try-finally / tagged-template / increment-in-lambda patterns so babel-plugin-react-compiler can lower these files. --- .../DatePicker/CalendarPicker/MonthPickerModal.tsx | 7 ++++--- .../DatePicker/CalendarPicker/YearPickerModal.tsx | 5 +++-- src/components/Modal/index.tsx | 3 ++- src/components/TextLink.tsx | 4 +++- src/components/Tooltip/GenericTooltip.tsx | 4 +++- src/hooks/useDeleteTransactions.ts | 4 +++- .../AppNavigator/Navigators/Overlay/BaseOverlay.tsx | 4 +++- .../ReportAction/DebugReportActionCreatePage.tsx | 3 +-- .../DebugTransactionViolationCreatePage.tsx | 3 +-- .../components/BankAccountValidationForm.tsx | 2 +- src/pages/TransactionDuplicate/ReviewFields.tsx | 12 ++++++++---- 11 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx index e13858c1b309..5115a95e05d9 100644 --- a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx @@ -31,13 +31,14 @@ type MonthPickerModalProps = { shouldEnableBackdropInNarrowPane?: boolean; }; -function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) { +function MonthPickerModal({isVisible, currentMonth, onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); const [searchText, setSearchText] = useState(''); + const resolvedCurrentMonth = currentMonth ?? new Date().getMonth(); const monthNames = DateUtils.getMonthNames(dateFnsLocale); - const allMonths = useMemo(() => DateUtils.getFilteredMonthItems(monthNames, currentMonth), [monthNames, currentMonth]); + const allMonths = useMemo(() => DateUtils.getFilteredMonthItems(monthNames, resolvedCurrentMonth), [monthNames, resolvedCurrentMonth]); const {data, headerMessage} = useMemo(() => { const filteredMonths = searchText === '' ? allMonths : allMonths.filter((month) => month.text.toLowerCase().includes(searchText.toLowerCase())); @@ -94,7 +95,7 @@ function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMo onMonthChange?.(option.value); }} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentMonth.toString()} + initiallyFocusedItemKey={resolvedCurrentMonth.toString()} disableMaintainingScrollPosition addBottomSafeAreaPadding shouldStopPropagation diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index fdd23e69f946..aecf953cd31b 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -34,7 +34,8 @@ type YearPickerModalProps = { shouldEnableBackdropInNarrowPane?: boolean; }; -function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear(), onYearChange, onClose, shouldEnableBackdropInNarrowPane = false}: YearPickerModalProps) { +function YearPickerModal({isVisible, years, currentYear, onYearChange, onClose, shouldEnableBackdropInNarrowPane = false}: YearPickerModalProps) { + const resolvedCurrentYear = currentYear ?? new Date().getFullYear(); const styles = useThemeStyles(); const {translate} = useLocalize(); const [searchText, setSearchText] = useState(''); @@ -95,7 +96,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear onYearChange?.(option.value); }} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentYear.toString()} + initiallyFocusedItemKey={resolvedCurrentYear.toString()} disableMaintainingScrollPosition addBottomSafeAreaPadding shouldStopPropagation diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 5a2a87897a58..9a23ab6d200e 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -61,8 +61,9 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = ( const [previousStatusBarColor, setPreviousStatusBarColor] = useState(); const webAnimation = getWebModalAnimation(type, isSmallScreenWidth); + const defaultStatusBarColor = theme.appBG; - const setStatusBarColor = (color = theme.appBG) => { + const setStatusBarColor = (color = defaultStatusBarColor) => { if (!fullscreen) { return; } diff --git a/src/components/TextLink.tsx b/src/components/TextLink.tsx index ad0bd1225d3b..49fd4a2b27f6 100644 --- a/src/components/TextLink.tsx +++ b/src/components/TextLink.tsx @@ -40,7 +40,9 @@ type TextLinkProps = (LinkProps | PressProps) & suppressDefaultStyle?: boolean; }; -function TextLink({href, onPress, children, style, onMouseDown = (event) => event.preventDefault(), suppressDefaultStyle = false, ref, ...rest}: TextLinkProps) { +const defaultOnMouseDown: MouseEventHandler = (event) => event.preventDefault(); + +function TextLink({href, onPress, children, style, onMouseDown = defaultOnMouseDown, suppressDefaultStyle = false, ref, ...rest}: TextLinkProps) { const {environmentURL} = useEnvironment(); const styles = useThemeStyles(); diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index 45c97176c4ea..2132e97e8f32 100644 --- a/src/components/Tooltip/GenericTooltip.tsx +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -24,10 +24,12 @@ import TooltipSense from './TooltipSense'; * The generic tooltip implementation, exposing the tooltip's state * while leaving the tooltip's target bounds computation to its parent. */ +const defaultTooltipMaxWidth = variables.sideBarWidth - 2 * variables.uploadViewMargin; + function GenericTooltip({ children, numberOfLines = CONST.TOOLTIP_MAX_LINES, - maxWidth = variables.sideBarWidth - 2 * variables.uploadViewMargin, + maxWidth = defaultTooltipMaxWidth, minWidth, text = '', renderTooltipContent, diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 50e7aa32e0d2..31ca669a09c5 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -222,7 +222,9 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac !isExpenseSplit && !!originalTransactionID && isSplitChildTransaction(transaction) && !originalTransaction?.comment?.splits && isIOUReport(transactionCurrentReport); if ((isExpenseSplit || isMovedExpenseSplitChild) && originalTransactionID) { - acc.splitTransactionsByOriginalTransactionID[originalTransactionID] ??= []; + if (acc.splitTransactionsByOriginalTransactionID[originalTransactionID] == null) { + acc.splitTransactionsByOriginalTransactionID[originalTransactionID] = []; + } acc.splitTransactionsByOriginalTransactionID[originalTransactionID].push(item); } else { acc.nonSplitTransactions.push(item); diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx index 9039607df582..37bc0845e78e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx @@ -27,8 +27,10 @@ type BaseOverlayProps = { positionRightValue?: number | Animated.Value | Animated.AnimatedAddition; }; +const defaultPositionLeftValue = -2 * variables.sideBarWidth; + // The default value of positionLeftValue is equal to -2 * variables.sideBarWidth, because we need to stretch the overlay to cover the sidebar and the translate animation distance. -function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) { +function BaseOverlay({onPress, progress, positionLeftValue = defaultPositionLeftValue, positionRightValue = 0}: BaseOverlayProps) { const styles = useThemeStyles(); const {current} = useCardAnimation(); const {translate} = useLocalize(); diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 74e4893a2b5f..822edcbdbd57 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -97,9 +97,8 @@ function DebugReportActionCreatePage({ } catch (e) { const {cause, message} = e as SyntaxError; setError(cause ? translate(message as TranslationPaths, cause as never) : message); - } finally { - setDraftReportAction(updatedJSON); } + setDraftReportAction(updatedJSON); }, [translate], ); diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx index 4cad88412734..57b320ba0c08 100644 --- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx +++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx @@ -83,9 +83,8 @@ function DebugTransactionViolationCreatePage({ } catch (e) { const {cause, message} = e as SyntaxError; setError(cause ? translate(message as TranslationPaths, cause as never) : message); - } finally { - setDraftTransactionViolation(updatedJSON); } + setDraftTransactionViolation(updatedJSON); }, [translate], ); diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx index e7a73eff472a..2a73b4e0f2c9 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx @@ -75,7 +75,7 @@ function BankAccountValidationForm({requiresTwoFactorAuth, reimbursementAccount, const errors: FormInputErrors = {}; const amountValues = getAmountValues(values); const outputCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; - const amountRegex = RegExp(String.raw`^-?\d{0,${CONST.IOU.AMOUNT_MAX_LENGTH}}([${permittedDecimalSeparator}]\d{0,${getCurrencyDecimals(outputCurrency)}})?$`, 'i'); + const amountRegex = RegExp(`^-?\\d{0,${CONST.IOU.AMOUNT_MAX_LENGTH}}([${permittedDecimalSeparator}]\\d{0,${getCurrencyDecimals(outputCurrency)}})?$`, 'i'); for (const key of Object.keys(amountValues)) { const value = amountValues[key as keyof AmountValues]; diff --git a/src/pages/TransactionDuplicate/ReviewFields.tsx b/src/pages/TransactionDuplicate/ReviewFields.tsx index e031db5322cc..eacb885e34fc 100644 --- a/src/pages/TransactionDuplicate/ReviewFields.tsx +++ b/src/pages/TransactionDuplicate/ReviewFields.tsx @@ -44,13 +44,17 @@ function ReviewFields({stepNames, label, optio const styles = useThemeStyles(); const {translate} = useLocalize(); - let falsyCount = 0; + const noneText = translate('violations.none'); + let seenNone = false; const filteredOptions = options?.filter((name) => { - if (name.text !== translate('violations.none')) { + if (name.text !== noneText) { return true; } - falsyCount++; - return falsyCount <= 1; + if (seenNone) { + return false; + } + seenNone = true; + return true; }); const optionRows = useMemo( From 6387ff4aec5f72135500f7dda74a94b2d985a6a6 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 1 Sep 2026 11:34:19 -0700 Subject: [PATCH 089/485] Let React Compiler own memoization in more files Remove manual useMemo/useCallback that the compiler could not preserve, so those components compile and auto-memoize. --- src/components/TimePicker/TimePicker.tsx | 174 ++++++++---------- src/pages/DynamicReportDetailsPage.tsx | 108 +++++------ src/pages/ScheduleCall/ScheduleCallPage.tsx | 15 +- src/pages/inbox/sidebar/SidebarLinks.tsx | 5 +- .../categories/CategorySettingsPage.tsx | 142 +++++--------- .../WorkspaceDuplicateSelectFeaturesForm.tsx | 135 +++++--------- .../upgrade/WorkspaceUpgradePage.tsx | 32 +--- 7 files changed, 220 insertions(+), 391 deletions(-) diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index f6ee710edfea..8837daaa0eb3 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -559,63 +559,59 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou * Update amount with number or Backspace pressed for BigNumberPad. * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button */ - const updateAmountNumberPad = useCallback( - (key: string) => { - const isHourFocused = hourInputRef.current?.isFocused(); - const isMinuteFocused = minuteInputRef.current?.isFocused(); - const isSecondFocused = secondInputRef.current?.isFocused(); - const isMillisecondFocused = millisecondInputRef.current?.isFocused(); - if (showFullFormat && !isHourFocused && !isMinuteFocused && !isSecondFocused && !isMillisecondFocused) { - millisecondInputRef.current?.focus(); - } else if (!showFullFormat && !isHourFocused && !isMinuteFocused) { - minuteInputRef.current?.focus(); - } - - if (key === '.') { - return; - } - if (key === '<' || key === 'Backspace') { - if (isHourFocused) { - clearSelectedValue(hours, selectionHour, setHours, setSelectionHour); - } else if (isMinuteFocused) { - if (selectionMinute.start === 0 && selectionMinute.end === 0) { - focusHourInputOnLastCharacter(); - return; - } - - clearSelectedValue(minutes, selectionMinute, setMinutes, setSelectionMinute); - } else if (isSecondFocused) { - if (selectionSecond.start === 0 && selectionSecond.end === 0) { - focusMinuteInputOnLastCharacter(); - return; - } - - clearSelectedValue(seconds, selectionSecond, setSeconds, setSelectionSecond); - } else if (isMillisecondFocused) { - if (selectionMillisecond.start === 0 && selectionMillisecond.end === 0) { - focusSecondInputOnLastCharacter(); - return; - } - - clearSelectedValue(milliseconds, selectionMillisecond, setMilliseconds, setSelectionMillisecond, 3); - } - return; - } - const trimmedKey = key.replaceAll(/[^0-9]/g, ''); + const updateAmountNumberPad = (key: string) => { + const isHourFocused = hourInputRef.current?.isFocused(); + const isMinuteFocused = minuteInputRef.current?.isFocused(); + const isSecondFocused = secondInputRef.current?.isFocused(); + const isMillisecondFocused = millisecondInputRef.current?.isFocused(); + if (showFullFormat && !isHourFocused && !isMinuteFocused && !isSecondFocused && !isMillisecondFocused) { + millisecondInputRef.current?.focus(); + } else if (!showFullFormat && !isHourFocused && !isMinuteFocused) { + minuteInputRef.current?.focus(); + } + if (key === '.') { + return; + } + if (key === '<' || key === 'Backspace') { if (isHourFocused) { - handleHourChange(insertAtPosition(hours, trimmedKey, selectionHour.start, selectionHour.end)); + clearSelectedValue(hours, selectionHour, setHours, setSelectionHour); } else if (isMinuteFocused) { - handleMinutesChange(insertAtPosition(minutes, trimmedKey, selectionMinute.start, selectionMinute.end)); + if (selectionMinute.start === 0 && selectionMinute.end === 0) { + focusHourInputOnLastCharacter(); + return; + } + + clearSelectedValue(minutes, selectionMinute, setMinutes, setSelectionMinute); } else if (isSecondFocused) { - handleSecondsChange(insertAtPosition(seconds, trimmedKey, selectionSecond.start, selectionSecond.end)); + if (selectionSecond.start === 0 && selectionSecond.end === 0) { + focusMinuteInputOnLastCharacter(); + return; + } + + clearSelectedValue(seconds, selectionSecond, setSeconds, setSelectionSecond); } else if (isMillisecondFocused) { - handleMillisecondsChange(insertAtPosition(milliseconds, trimmedKey, selectionMillisecond.start, selectionMillisecond.end)); + if (selectionMillisecond.start === 0 && selectionMillisecond.end === 0) { + focusSecondInputOnLastCharacter(); + return; + } + + clearSelectedValue(milliseconds, selectionMillisecond, setMilliseconds, setSelectionMillisecond, 3); } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [minutes, hours, seconds, milliseconds, selectionMinute, selectionHour, selectionSecond, selectionMillisecond], - ); + return; + } + const trimmedKey = key.replaceAll(/[^0-9]/g, ''); + + if (isHourFocused) { + handleHourChange(insertAtPosition(hours, trimmedKey, selectionHour.start, selectionHour.end)); + } else if (isMinuteFocused) { + handleMinutesChange(insertAtPosition(minutes, trimmedKey, selectionMinute.start, selectionMinute.end)); + } else if (isSecondFocused) { + handleSecondsChange(insertAtPosition(seconds, trimmedKey, selectionSecond.start, selectionSecond.end)); + } else if (isMillisecondFocused) { + handleMillisecondsChange(insertAtPosition(milliseconds, trimmedKey, selectionMillisecond.start, selectionMillisecond.end)); + } + }; useEffect(() => { // we implement this to ensure the hour input focuses on the first character upon initial focus @@ -630,48 +626,40 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou [], ); - const arrowLeftCallback = useCallback( - (e?: GestureResponderEvent | KeyboardEvent) => { - if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusHourInputOnLastCharacter(); - } - if (secondInputRef.current?.isFocused() && selectionSecond.start === 0) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusMinuteInputOnLastCharacter(); - } - if (millisecondInputRef.current?.isFocused() && selectionMillisecond.start === 0) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusSecondInputOnLastCharacter(); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectionHour, selectionMinute.start], - ); - const arrowRightCallback = useCallback( - (e?: GestureResponderEvent | KeyboardEvent) => { - if (hourInputRef.current?.isFocused() && selectionHour.start === 2) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusMinuteInputOnFirstCharacter(); - } - if (minuteInputRef.current?.isFocused() && selectionMinute.start === 2) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusSecondInputOnFirstCharacter(); - } - if (secondInputRef.current?.isFocused() && selectionSecond.start === 2) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusMillisecondInputOnFirstCharacter(); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectionHour.start, selectionMinute.start, selectionSecond.start, selectionMillisecond], - ); + const arrowLeftCallback = (e?: GestureResponderEvent | KeyboardEvent) => { + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusHourInputOnLastCharacter(); + } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 0) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusMinuteInputOnLastCharacter(); + } + if (millisecondInputRef.current?.isFocused() && selectionMillisecond.start === 0) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusSecondInputOnLastCharacter(); + } + }; + const arrowRightCallback = (e?: GestureResponderEvent | KeyboardEvent) => { + if (hourInputRef.current?.isFocused() && selectionHour.start === 2) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusMinuteInputOnFirstCharacter(); + } + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 2) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusSecondInputOnFirstCharacter(); + } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 2) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusMillisecondInputOnFirstCharacter(); + } + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig); diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 33f2e711b0e5..e668a943d263 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -241,8 +241,13 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID; + const currentUserEmail = currentUserPersonalDetails?.email; + const currentUserLogin = currentUserPersonalDetails?.login; + const currentUserDisplayName = currentUserPersonalDetails?.displayName; + const currentUserLocalCurrencyCode = currentUserPersonalDetails?.localCurrencyCode; const {getCurrencyDecimals} = useCurrencyListActions(); - const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserPersonalDetails?.email), [currentUserPersonalDetails?.email]); + const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserEmail), [currentUserEmail]); const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); const {showConfirmModal} = useConfirmModal(); const reportForHeader = useMemo(() => getReportForHeader(report, parentReport), [report, parentReport]); @@ -250,7 +255,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); const derivedHeaderReportName = getReportNameFromNames(derivedReportNames, reportForHeader?.reportID); const isPolicyAdmin = useMemo(() => isPolicyAdminUtil(policy), [policy]); - const isPolicyEmployee = useMemo(() => isPolicyEmployeeUtil(report?.policyID, policy), [report?.policyID, policy]); + const isPolicyEmployee = isPolicyEmployeeUtil(report?.policyID, policy); const isPolicyExpenseChat = useMemo(() => isPolicyExpenseChatUtil(report), [report]); const isChatRoom = useMemo(() => isChatRoomUtil(report), [report]); const isUserCreatedPolicyRoom = useMemo(() => isUserCreatedPolicyRoomUtil(report), [report]); @@ -266,8 +271,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const isTrackExpenseReport = useMemo(() => isTrackExpenseReportUtil(report, parentReport, parentReportAction), [report, parentReport, parentReportAction]); const isCanceledTaskReport = isCanceledTaskReportUtil(report, parentReportAction); const isParentReportArchived = useReportIsArchived(parentReport?.reportID); - const isTaskModifiable = canModifyTask(report, currentUserPersonalDetails?.accountID, isParentReportArchived); - const isTaskActionable = canActionTask(report, parentReportAction, currentUserPersonalDetails?.accountID, parentReport, isParentReportArchived); + const isTaskModifiable = canModifyTask(report, currentUserAccountID, isParentReportArchived); + const isTaskActionable = canActionTask(report, parentReportAction, currentUserAccountID, parentReport, isParentReportArchived); const canEditReportDescription = useMemo(() => canEditReportDescriptionUtil(report, policy), [report, policy]); const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== '') && (isTaskReport ? isTaskModifiable : true); const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; @@ -322,24 +327,14 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report }); const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; - const requestParentReportAction = useMemo(() => { - // 2. MoneyReport case - if (caseID === CASES.MONEY_REPORT) { - if (!reportActions || !transactionThreadReport?.parentReportActionID) { - return undefined; - } - return reportActions.find((action) => action.reportActionID === transactionThreadReport.parentReportActionID); - } - return parentReportAction; - }, [caseID, parentReportAction, reportActions, transactionThreadReport?.parentReportActionID]); + const transactionThreadParentReportActionID = transactionThreadReport?.parentReportActionID; + const requestParentReportAction = caseID === CASES.MONEY_REPORT ? reportActions?.find((action) => action.reportActionID === transactionThreadParentReportActionID) : parentReportAction; const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(requestParentReportAction); const [iouPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`); const [requestParentReportActionChildReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(requestParentReportAction?.childReportID)}`); const isActionOwner = - typeof requestParentReportAction?.actorAccountID === 'number' && - typeof currentUserPersonalDetails?.accountID === 'number' && - requestParentReportAction.actorAccountID === currentUserPersonalDetails?.accountID; + typeof requestParentReportAction?.actorAccountID === 'number' && typeof currentUserAccountID === 'number' && requestParentReportAction.actorAccountID === currentUserAccountID; const isDeletedParentAction = isDeletedAction(requestParentReportAction); const moneyRequestReport: OnyxEntry = useMemo(() => { @@ -363,7 +358,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); const [iouOriginalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransaction?.comment?.originalTransactionID)}`); - const hasWorkspaceToSubmitToSelector = useMemo(() => createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login), [currentUserPersonalDetails.login]); + const hasWorkspaceToSubmitToSelector = useMemo(() => createHasWorkspaceToSubmitToSelector(currentUserLogin), [currentUserLogin]); const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({ @@ -393,32 +388,13 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const leaveChat = useCallback(() => { if (isRootGroupChat) { - leaveGroupChat( - report, - quickAction?.chatReportID?.toString() === report.reportID, - currentUserPersonalDetails.accountID, - conciergeReportID, - introSelected, - isSelfTourViewed, - betas, - ); + leaveGroupChat(report, quickAction?.chatReportID?.toString() === report.reportID, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, betas); return; } const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin); - leaveRoom(report, currentUserPersonalDetails.accountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom); - }, [ - isRootGroupChat, - isPolicyEmployee, - isPolicyAdmin, - quickAction?.chatReportID, - report, - currentUserPersonalDetails.accountID, - conciergeReportID, - introSelected, - isSelfTourViewed, - betas, - ]); + leaveRoom(report, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom); + }, [isRootGroupChat, isPolicyEmployee, isPolicyAdmin, quickAction?.chatReportID, report, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, betas]); const showLastMemberLeavingModal = useCallback(async () => { const {action} = await showConfirmModal({ @@ -435,7 +411,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report leaveChat(); }, [showConfirmModal, translate, leaveChat]); - const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserPersonalDetails?.accountID, !!reportNameValuePairs?.private_isArchived); + const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserAccountID, !!reportNameValuePairs?.private_isArchived); // Snapshot on focus whether the room is the screen behind the Details page, so the row doesn't flip while the page // is closing after it's tapped, yet still reflects the correct screen on later visits. @@ -444,7 +420,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report setIsRoomCurrentlyOpen(isReportTopmostSplitNavigator() && Navigation.getTopmostReportId() === report?.reportID); }); const shouldShowGoToRoom = (isChatRoom || isPolicyExpenseChat) && !isRoomCurrentlyOpen; - const shouldShowGoToWorkspace = shouldShowPolicy(policy, false, currentUserPersonalDetails?.email) && !policy?.isJoinRequestPending && !shouldShowGoToRoom; + const shouldShowGoToWorkspace = shouldShowPolicy(policy, false, currentUserEmail) && !policy?.isJoinRequestPending && !shouldShowGoToRoom; const shouldParseFullTitle = parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && !isGroupChat; const rawReportName = getReportName(reportForHeader, derivedHeaderReportName); @@ -546,7 +522,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report if (isTrackExpenseReport && !isDeletedParentAction) { const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID, moneyRequestReportActions); const actionableWhisperReportActionID = whisperAction?.reportActionID; - const currentUserLocalCurrency = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; + const currentUserLocalCurrency = currentUserLocalCurrencyCode ?? CONST.CURRENCY.USD; const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); // Hide the "Submit it to someone" option for self-DM split expenses when the user isn't a member of any workspace. @@ -564,15 +540,15 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report isRestrictedToPreferredPolicy, preferredPolicyID, transaction: iouTransaction, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, firstPolicyID: filteredPoliciesInfo?.firstPolicyID, }; // "Submit to someone" splits into two destinations here too, matching the track-expense whisper: // submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). - const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserPersonalDetails.email ?? '', lastWorkspaceNumber, translate, currentUserPersonalDetails.displayName); + const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserEmail ?? '', lastWorkspaceNumber, translate, currentUserDisplayName); // Self-DM split expenses can only be submitted to a workspace, so the "a friend" destination is omitted here // just like it is on the track-expense whisper. @@ -629,8 +605,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report amountOwed, ownerBillingGracePeriodEnd, transaction: iouTransaction, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, firstPolicyID: filteredPoliciesInfo?.firstPolicyID, @@ -656,8 +632,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report amountOwed, ownerBillingGracePeriodEnd, transaction: iouTransaction, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, firstPolicyID: filteredPoliciesInfo?.firstPolicyID, @@ -675,7 +651,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report icon: expensifyIcons.Pencil, isAnonymousAction: false, shouldShowRightIcon: true, - action: () => navigateToPrivateNotes(report, currentUserPersonalDetails.accountID), + action: () => navigateToPrivateNotes(report, currentUserAccountID), brickRoadIndicator: hasErrorInPrivateNotes(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, }); } @@ -690,7 +666,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report isAnonymousAction: false, action: callFunctionIfActionIsAllowed(() => { Navigation.goBack(navigateBackFromReportDetailsPath); - reopenTask(report, parentReport, currentUserPersonalDetails?.accountID, delegateEmail); + reopenTask(report, parentReport, currentUserAccountID, delegateEmail); }), }); } @@ -788,9 +764,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report iouTransactionID, moneyRequestReport?.reportID, moneyRequestReportActions, - currentUserPersonalDetails.accountID, - currentUserPersonalDetails.email, - currentUserPersonalDetails.localCurrencyCode, + currentUserAccountID, + currentUserEmail, + currentUserLocalCurrencyCode, isTaskActionable, isRootGroupChat, leaveChat, @@ -814,7 +790,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report conciergeReportID, lastWorkspaceNumber, translate, - currentUserPersonalDetails.displayName, + currentUserDisplayName, ]); const icons = useMemo( @@ -831,7 +807,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report report={report} policy={policy} participants={participants} - currentUserAccountID={currentUserPersonalDetails.accountID} + currentUserAccountID={currentUserAccountID} /> ); @@ -889,7 +865,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report participants, moneyRequestReport?.reportID, expensifyIcons.Camera, - currentUserPersonalDetails?.accountID, + currentUserAccountID, ]); const canJoin = canJoinChat(report, parentReportAction, policy, parentReport, !!reportNameValuePairs?.private_isArchived); @@ -898,7 +874,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const result: PromotedAction[] = []; if (canJoin) { - result.push(PromotedActions.join(report, currentUserPersonalDetails.accountID)); + result.push(PromotedActions.join(report, currentUserAccountID)); } if (report) { @@ -908,7 +884,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report result.push(PromotedActions.share()); return result; - }, [canJoin, report, currentUserPersonalDetails.accountID]); + }, [canJoin, report, currentUserAccountID]); const shouldDisplayGroupWorkspaceAsPushRow = !isThread && (isGroupChat || isUserCreatedPolicyRoom || isDefaultRoom); const nameSectionGroupWorkspace = ( @@ -957,7 +933,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const fieldKey = getReportFieldKey(titleField?.fieldID); const isFieldDisabled = isReportFieldDisabled(report, titleField, policy); - const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserPersonalDetails?.accountID); + const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserAccountID); const nameSectionFurtherDetailsContent = ( = useMemo(() => { + const timeSlotDateMap: Record = (() => { if (!calendlySchedule?.data) { return {}; } @@ -135,7 +135,7 @@ function ScheduleCallPage() { } return timeSlotMap; - }, [calendlySchedule?.data, userTimezone, dateFnsLocale]); + })(); const selectableDates = Object.keys(timeSlotDateMap).sort(compareAsc); const firstDate = selectableDates.at(0); @@ -152,12 +152,8 @@ function ScheduleCallPage() { }, [firstDate, calendlySchedule?.isLoading, scheduleCallDraft?.date]); // When there is only one time slot on the row, it will take full width of the row, use a hidden filler item to keep 2 columns - const timeFillerItem = useMemo(() => { - if (timeSlotsForSelectedData.length % 2 === 0) { - return null; - } - - return ( + const timeFillerItem = + timeSlotsForSelectedData.length % 2 === 0 ? null : ( ); - }, [styles.twoColumnLayoutCol, styles.visibilityHidden, timeSlotsForSelectedData.length]); return ( StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]), [insets]); + const contentContainerStyles = StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]); const shouldShowEmptyLHN = optionListItems.length === 0; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 10595d6c725d..7d88fdecd7f6 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -55,7 +55,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type CategorySettingsPageProps = @@ -86,20 +86,17 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti const decodedCategoryName = getDecodedCategoryName(policyCategory?.name ?? ''); const categoryRulesEnabled = arePolicyRulesEnabled(policy, policyCategories, isRulesRevampEnabled); - const contextualRules = useMemo(() => { - if (!isRulesRevampEnabled || !policyCategory) { - return []; - } - - return getCategoryContextualRules({ - policy, - category: policyCategory, - categoryName: policyCategory.name, - translate, - convertToDisplayString, - isOffline, - }); - }, [convertToDisplayString, isOffline, isRulesRevampEnabled, policy, policyCategory, translate]); + const contextualRules = + !isRulesRevampEnabled || !policyCategory + ? [] + : getCategoryContextualRules({ + policy, + category: policyCategory, + categoryName: policyCategory.name, + translate, + convertToDisplayString, + isOffline, + }); const shouldPreventDisableOrDelete = isDisablingOrDeletingLastEnabledCategory(policy, policyData.categories, [policyCategory]); const isQuickSettingsFlow = name === SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS; @@ -137,107 +134,54 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti navigation.setParams({categoryName: policyCategory?.name}); }, [categoryName, navigation, policyCategory?.name, isFocused]); - const flagAmountsOverText = useMemo(() => { - if (policyCategory?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policyCategory?.maxExpenseAmount) { - return ''; - } - - return `${convertToDisplayString(policyCategory?.maxExpenseAmount, policyCurrency)} ${CONST.DOT_SEPARATOR} ${translate( - `workspace.rules.categoryRules.expenseLimitTypes.${policyCategoryExpenseLimitType}`, - )}`; - }, [convertToDisplayString, policyCategory?.maxExpenseAmount, policyCategoryExpenseLimitType, policyCurrency, translate]); + const flagAmountsOverText = + policyCategory?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE || !policyCategory?.maxExpenseAmount + ? '' + : `${convertToDisplayString(policyCategory?.maxExpenseAmount, policyCurrency)} ${CONST.DOT_SEPARATOR} ${translate( + `workspace.rules.categoryRules.expenseLimitTypes.${policyCategoryExpenseLimitType}`, + )}`; const categoryApprover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], categoryName)?.approver ?? ''; const approverText = usePersonalDetailByLogin(categoryApprover, (personalDetails) => formatPhoneNumber(personalDetails?.displayName ?? categoryApprover)); - const defaultTaxRateText = useMemo(() => { - const taxID = getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); - - if (!taxID) { - return ''; - } - - const taxRate = policy?.taxRates?.taxes[taxID]; + const taxID = getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); + const taxRate = taxID ? policy?.taxRates?.taxes[taxID] : undefined; + const defaultTaxRateText = !taxID || !taxRate ? '' : formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates); - if (!taxRate) { - return ''; - } + const requireReceiptsOverText = !policy ? '' : formatRequireReceiptsOverText(translate, policy, policyCategory?.maxAmountNoReceipt, convertToDisplayString); - return formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates); - }, [categoryName, policy?.rules?.expenseRules, policy?.taxRates, translate]); + const requireItemizedReceiptsOverText = !policy ? '' : formatRequireItemizedReceiptsOverText(translate, policy, policyCategory?.maxAmountNoItemizedReceipt, convertToDisplayString); - const requireReceiptsOverText = useMemo(() => { - if (!policy) { - return ''; - } - return formatRequireReceiptsOverText(translate, policy, policyCategory?.maxAmountNoReceipt, convertToDisplayString); - }, [policy, policyCategory?.maxAmountNoReceipt, translate, convertToDisplayString]); + const requiredFieldsTitle = !policyCategory ? '' : formatRequiredFieldsTitle(translate, policyCategory, isAttendeeTrackingEnabled(policy)); - const requireItemizedReceiptsOverText = useMemo(() => { - if (!policy) { - return ''; - } - return formatRequireItemizedReceiptsOverText(translate, policy, policyCategory?.maxAmountNoItemizedReceipt, convertToDisplayString); - }, [policy, policyCategory?.maxAmountNoItemizedReceipt, translate, convertToDisplayString]); - - const requiredFieldsTitle = useMemo(() => { - if (!policyCategory) { - return ''; - } - return formatRequiredFieldsTitle(translate, policyCategory, isAttendeeTrackingEnabled(policy)); - }, [policyCategory, translate, policy]); - - const requireFieldsPendingAction = useMemo(() => { - if (isAttendeeTrackingEnabled(policy)) { - // Pending fields are objects so we can't use nullish coalescing - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return policyCategory?.pendingFields?.areAttendeesRequired || policyCategory?.pendingFields?.areCommentsRequired; - } - return policyCategory?.pendingFields?.areCommentsRequired; - }, [policyCategory?.pendingFields, policy]); + const requireFieldsPendingAction = isAttendeeTrackingEnabled(policy) + ? // Pending fields are objects so we can't use nullish coalescing + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + policyCategory?.pendingFields?.areAttendeesRequired || policyCategory?.pendingFields?.areCommentsRequired + : policyCategory?.pendingFields?.areCommentsRequired; // eslint-disable-next-line rulesdir/no-negated-variables - const showCannotDeleteOrDisableLastCategoryModal = useCallback(() => { + const showCannotDeleteOrDisableLastCategoryModal = () => { showConfirmModal({ title: translate('workspace.categories.cannotDeleteOrDisableAllCategories.title'), prompt: translate('workspace.categories.cannotDeleteOrDisableAllCategories.description'), confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, }); - }, [showConfirmModal, translate]); - - const updateWorkspaceCategoryEnabled = useCallback( - (value: boolean) => { - if (shouldPreventDisableOrDelete) { - showCannotDeleteOrDisableLastCategoryModal(); - return; - } - setWorkspaceCategoryEnabled({ - policyData, - categoriesToUpdate: {[policyCategory.name]: {name: policyCategory.name, enabled: value}}, - isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, - setupCategoryTaskReport, - setupCategoryTaskParentReport, - currentUserAccountID: currentUserPersonalDetails.accountID, - hasOutstandingChildTask, - parentReportAction, - setupCategoriesAndTagsTaskReport, - setupCategoriesAndTagsTaskParentReport, - isSetupCategoriesAndTagsTaskParentReportArchived, - setupCategoriesAndTagsHasOutstandingChildTask, - setupCategoriesAndTagsParentReportAction, - policyHasTags, - }); - }, - [ - showCannotDeleteOrDisableLastCategoryModal, - shouldPreventDisableOrDelete, + }; + + const updateWorkspaceCategoryEnabled = (value: boolean) => { + if (shouldPreventDisableOrDelete) { + showCannotDeleteOrDisableLastCategoryModal(); + return; + } + setWorkspaceCategoryEnabled({ policyData, - policyCategory?.name, - isSetupCategoryTaskParentReportArchived, + categoriesToUpdate: {[policyCategory.name]: {name: policyCategory.name, enabled: value}}, + isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, - currentUserPersonalDetails.accountID, + currentUserAccountID: currentUserPersonalDetails.accountID, hasOutstandingChildTask, parentReportAction, setupCategoriesAndTagsTaskReport, @@ -246,8 +190,8 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti setupCategoriesAndTagsHasOutstandingChildTask, setupCategoriesAndTagsParentReportAction, policyHasTags, - ], - ); + }); + }; const navigateToEditCategory = () => { Navigation.navigate(isQuickSettingsFlow ? createDynamicRoute(DYNAMIC_ROUTES.SETTINGS_CATEGORY_EDIT.path) : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_EDIT.path)); diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx index 46dc226da347..8967e88d5e65 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -29,7 +29,7 @@ import ROUTES from '@src/ROUTES'; import type {Rate} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {getAllValidConnectedIntegration, getWorkflowRules, getWorkspaceRules} from './utils'; @@ -76,19 +76,16 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const customUnit = getDistanceRateCustomUnit(policy); const ratesCount = Object.values(customUnit?.rates ?? {}).filter((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; - const totalTags = useMemo(() => { - if (!policyTags) { - return 0; - } - return Object.values(policyTags).reduce( - (sum, tagGroup) => sum + Object.values(tagGroup.tags ?? {}).filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length, - 0, - ); - }, [policyTags]); + const totalTags = !policyTags + ? 0 + : Object.values(policyTags).reduce( + (sum, tagGroup) => sum + Object.values(tagGroup.tags ?? {}).filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length, + 0, + ); const formattedAddress = !isEmptyObject(policy) && !isEmptyObject(policy.address) ? formatAddressToString(policy.address) : ''; - const items = useMemo(() => { + const items = (() => { const rules = getWorkspaceRules(policy, translate); const workflows = getWorkflowRules(policy, translate); @@ -194,43 +191,26 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm ]; return result.filter((item): item is NonNullable => !!item); - }, [ - policy, - translate, - formattedAddress, - totalMembers, - reportFields, - connectedIntegration, - totalTags, - categoriesCount, - taxesLength, - ratesCount, - isCollect, - allRates, - invoiceConfigurationText, - codingRulesCount, - ]); - - const featuresToCopy: ListItem[] = useMemo(() => { - return items.map((option) => { - const alternateText = option?.alternateText ? option.alternateText.trim().replaceAll(/,$/g, '') : undefined; - return { - text: option.translation, - keyForList: option.value, - isSelected: selectedItems.includes(option.value), - alternateText, - }; - }); - }, [items, selectedItems]); + })(); + + const featuresToCopy: ListItem[] = items.map((option) => { + const alternateText = option?.alternateText ? option.alternateText.trim().replaceAll(/,$/g, '') : undefined; + return { + text: option.translation, + keyForList: option.value, + isSelected: selectedItems.includes(option.value), + alternateText, + }; + }); - const fetchWorkspaceRelatedData = useCallback(() => { + const fetchWorkspaceRelatedData = () => { if (!policyID) { return; } openDuplicatePolicyPage(policyID); - }, [policyID]); + }; - const confirmDuplicate = useCallback(() => { + const confirmDuplicate = () => { if (!policy || !duplicateWorkspace?.name || !duplicateWorkspace?.policyID) { return; } @@ -264,22 +244,11 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm localCurrency: currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD, }); Navigation.closeRHPFlow(); - }, [ - duplicateWorkspace?.name, - duplicateWorkspace?.policyID, - policy, - policyCategories, - selectedItems, - translate, - duplicatedWorkspaceAvatar, - currentUserPersonalDetails.accountID, - currentUserPersonalDetails.email, - currentUserPersonalDetails?.localCurrencyCode, - ]); + }; const duplicateWorkspaceName = duplicateWorkspace?.name; const duplicateWorkspacePolicyID = duplicateWorkspace?.policyID; - const onConfirmSelectList = useCallback(() => { + const onConfirmSelectList = () => { if (!totalMembers || totalMembers < 2 || !selectedItems.includes('members')) { confirmDuplicate(); return; @@ -307,43 +276,28 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm } confirmDuplicate(); }); - }, [ - confirmDuplicate, - duplicateWorkspaceName, - duplicateWorkspacePolicyID, - policy, - selectedItems, - showConfirmModal, - styles.mb3, - styles.textSupporting, - styles.webViewStyles.baseFontStyle, - totalMembers, - translate, - ]); - - const toggleAllItems = useCallback(() => { + }; + + const toggleAllItems = () => { if (selectedItems.length === items.length) { setSelectedItems([]); } else { setSelectedItems(items.map((i) => i.value)); } - }, [items, selectedItems.length]); + }; - const updateSelectedItems = useCallback( - (listItem: ListItem) => { - if (listItem.isSelected) { - setSelectedItems(selectedItems.filter((i) => i !== listItem.keyForList)); - return; - } + const updateSelectedItems = (listItem: ListItem) => { + if (listItem.isSelected) { + setSelectedItems(selectedItems.filter((i) => i !== listItem.keyForList)); + return; + } - const newItem = items.find((i) => i.value === listItem.keyForList)?.value; + const newItem = items.find((i) => i.value === listItem.keyForList)?.value; - if (newItem) { - setSelectedItems([...selectedItems, newItem]); - } - }, - [items, selectedItems], - ); + if (newItem) { + setSelectedItems([...selectedItems, newItem]); + } + }; // When the component mounts, if there is a new avatar, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow. // This is because until the request is saved, the avatar file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then @@ -379,14 +333,11 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const isSelectAllChecked = selectedItems.length > 0 && selectedItems.length === items.length; - const confirmButtonOptions: ConfirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.continue'), - onConfirm: onConfirmSelectList, - }), - [translate, onConfirmSelectList], - ); + const confirmButtonOptions: ConfirmButtonOptions = { + showButton: true, + text: translate('common.continue'), + onConfirm: onConfirmSelectList, + }; return ( <> diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index dfb0a63b4acf..c2a7d368499f 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -147,10 +147,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { const defaultApprover = getDefaultApprover(policy); - // useCallback is needed here because goBack is passed as a prop to child components; - // the rule flags it because the deps could be inlined, but removing useCallback would cause unnecessary re-renders. - // eslint-disable-next-line react-hooks/preserve-manual-memoization - const goBack = useCallback(() => { + const goBack = () => { if ((!feature && featureNameAlias !== CONST.UPGRADE_FEATURE_INTRO_MAPPING.policyPreventMemberChangingTitle.alias) || !policyID) { Navigation.dismissModal(); return; @@ -189,7 +186,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { default: return route.params.backTo ? Navigation.goBack(route.params.backTo) : Navigation.goBack(); } - }, [feature, policyID, route.params?.backTo, route.params?.featureName, featureNameAlias]); + }; const afterUpgradeAcknowledged = () => { if (feature?.id === CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.id && policyID) { @@ -215,10 +212,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { upgradeToCorporate(policy, feature?.name); }; - // useCallback is needed here because confirmUpgrade is passed as a prop to child components; - // the rule flags it because the deps could be inlined, but removing useCallback would cause unnecessary re-renders. - - const confirmUpgrade = useCallback(() => { + const confirmUpgrade = () => { if (!policyID) { return; } @@ -337,25 +331,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { default: break; } - }, [ - policyID, - feature, - featureNameAlias, - policy, - route.params.featureName, - perDiemCustomUnit?.customUnitID, - distanceRateCustomUnit, - governmentMileageRates, - defaultApprover, - accountID, - email, - qboConfig?.syncClasses, - qboConfig?.syncCustomers, - qboConfig?.syncLocations, - categoryId, - getReviewWorkspaceSettingsTaskCompletion, - isTrackIntentUser, - ]); + }; useWorkspaceUpgradeConfirmation({ policyID, From ec4d1cf439c946fa801a191fea2ef4d2a1d771f5 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 1 Sep 2026 12:04:06 -0700 Subject: [PATCH 090/485] Drop remaining manual memoization now that files compile These components/hooks now compile and auto-memoize on both React Compilers, so useMemo/useCallback is redundant. --- .../CalendarPicker/MonthPickerModal.tsx | 29 +- .../CalendarPicker/YearPickerModal.tsx | 31 +- src/components/TimePicker/TimePicker.tsx | 181 +++--- src/components/Tooltip/GenericTooltip.tsx | 14 +- src/hooks/useDeleteTransactions.ts | 567 ++++++++---------- .../DebugReportActionCreatePage.tsx | 33 +- .../DebugTransactionViolationCreatePage.tsx | 29 +- src/pages/DynamicReportDetailsPage.tsx | 249 ++------ .../components/BankAccountValidationForm.tsx | 25 +- src/pages/ScheduleCall/ScheduleCallPage.tsx | 14 +- .../TransactionDuplicate/ReviewFields.tsx | 18 +- src/pages/inbox/sidebar/SidebarLinks.tsx | 53 +- .../WorkspaceDuplicateSelectFeaturesForm.tsx | 9 +- .../upgrade/WorkspaceUpgradePage.tsx | 4 +- 14 files changed, 503 insertions(+), 753 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx index 5115a95e05d9..0186aca8f242 100644 --- a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx @@ -11,7 +11,7 @@ import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {Keyboard} from 'react-native'; type MonthPickerModalProps = { @@ -38,15 +38,11 @@ function MonthPickerModal({isVisible, currentMonth, onMonthChange, onClose, shou const resolvedCurrentMonth = currentMonth ?? new Date().getMonth(); const monthNames = DateUtils.getMonthNames(dateFnsLocale); - const allMonths = useMemo(() => DateUtils.getFilteredMonthItems(monthNames, resolvedCurrentMonth), [monthNames, resolvedCurrentMonth]); + const allMonths = DateUtils.getFilteredMonthItems(monthNames, resolvedCurrentMonth); - const {data, headerMessage} = useMemo(() => { - const filteredMonths = searchText === '' ? allMonths : allMonths.filter((month) => month.text.toLowerCase().includes(searchText.toLowerCase())); - return { - headerMessage: !filteredMonths.length ? translate('common.noResultsFound') : '', - data: filteredMonths, - }; - }, [allMonths, searchText, translate]); + const filteredMonths = searchText === '' ? allMonths : allMonths.filter((month) => month.text.toLowerCase().includes(searchText.toLowerCase())); + const headerMessage = !filteredMonths.length ? translate('common.noResultsFound') : ''; + const data = filteredMonths; useEffect(() => { if (isVisible) { @@ -55,15 +51,12 @@ function MonthPickerModal({isVisible, currentMonth, onMonthChange, onClose, shou setSearchText(''); }, [isVisible]); - const textInputOptions = useMemo( - () => ({ - label: translate('monthPickerPage.selectMonth'), - value: searchText, - onChangeText: setSearchText, - headerMessage, - }), - [headerMessage, searchText, translate], - ); + const textInputOptions = { + label: translate('monthPickerPage.selectMonth'), + value: searchText, + onChangeText: setSearchText, + headerMessage, + }; return ( { - const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); - return { - headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', - data: yearsList.sort((a, b) => b.value - a.value), - }; - }, [years, searchText, translate]); + const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); + const headerMessage = !yearsList.length ? translate('common.noResultsFound') : ''; + const data = yearsList.sort((a, b) => b.value - a.value); useEffect(() => { if (isVisible) { @@ -54,17 +50,14 @@ function YearPickerModal({isVisible, years, currentYear, onYearChange, onClose, setSearchText(''); }, [isVisible]); - const textInputOptions = useMemo( - () => ({ - label: translate('yearPickerPage.selectYear'), - value: searchText, - onChangeText: (text: string) => setSearchText(text.replaceAll(CONST.REGEX.NON_NUMERIC, '').trim()), - headerMessage, - maxLength: 4, - inputMode: CONST.INPUT_MODE.NUMERIC, - }), - [headerMessage, searchText, translate], - ); + const textInputOptions = { + label: translate('yearPickerPage.selectYear'), + value: searchText, + onChangeText: (text: string) => setSearchText(text.replaceAll(CONST.REGEX.NON_NUMERIC, '').trim()), + headerMessage, + maxLength: 4, + inputMode: CONST.INPUT_MODE.NUMERIC, + }; return ( {}, shou const {inputCallbackRef} = useAutoFocusInput(); - const focusMillisecondInputOnFirstCharacter = useCallback(() => setCursorPosition(0, millisecondInputRef, setSelectionMillisecond), []); - const focusSecondInputOnLastCharacter = useCallback(() => setCursorPosition(2, secondInputRef, setSelectionSecond), []); - const focusSecondInputOnFirstCharacter = useCallback(() => setCursorPosition(0, secondInputRef, setSelectionSecond), []); - const focusMinuteInputOnLastCharacter = useCallback(() => setCursorPosition(2, minuteInputRef, setSelectionMinute), []); - const focusMinuteInputOnFirstCharacter = useCallback(() => setCursorPosition(0, minuteInputRef, setSelectionMinute), []); - const focusHourInputOnLastCharacter = useCallback(() => setCursorPosition(2, hourInputRef, setSelectionHour), []); - - const validate = useCallback( - (time: string) => { - if (!shouldValidate) { - return true; - } - const timeString = time || `${hours}:${minutes} ${amPmValue}`; - const [hourStr] = timeString.split(/[:\s]+/); - const hour = parseInt(hourStr, 10); - if (hour === 0) { - setError(true); - setErrorMessage(translate('common.error.invalidTimeRange')); - return false; - } - if (!shouldValidateFutureTime) { - return true; - } - const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({timeString, dateTimeString: defaultValue}); - setError(!isValid); - setErrorMessage(translate('common.error.invalidTimeShouldBeFuture')); - return isValid; - }, - [shouldValidate, hours, minutes, amPmValue, shouldValidateFutureTime, defaultValue, translate], - ); + const focusMillisecondInputOnFirstCharacter = () => setCursorPosition(0, millisecondInputRef, setSelectionMillisecond); + const focusSecondInputOnLastCharacter = () => setCursorPosition(2, secondInputRef, setSelectionSecond); + const focusSecondInputOnFirstCharacter = () => setCursorPosition(0, secondInputRef, setSelectionSecond); + const focusMinuteInputOnLastCharacter = () => setCursorPosition(2, minuteInputRef, setSelectionMinute); + const focusMinuteInputOnFirstCharacter = () => setCursorPosition(0, minuteInputRef, setSelectionMinute); + const focusHourInputOnLastCharacter = () => setCursorPosition(2, hourInputRef, setSelectionHour); + + const validate = (time: string) => { + if (!shouldValidate) { + return true; + } + const timeString = time || `${hours}:${minutes} ${amPmValue}`; + const [hourStr] = timeString.split(/[:\s]+/); + const hour = parseInt(hourStr, 10); + if (hour === 0) { + setError(true); + setErrorMessage(translate('common.error.invalidTimeRange')); + return false; + } + if (!shouldValidateFutureTime) { + return true; + } + const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({timeString, dateTimeString: defaultValue}); + setError(!isValid); + setErrorMessage(translate('common.error.invalidTimeShouldBeFuture')); + return isValid; + }; const resetHours = () => { setHours('00'); @@ -619,12 +616,9 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou setSelectionHour({start: 0, end: 0}); }, []); - const arrowConfig = useMemo( - () => ({ - shouldPreventDefault: false, - }), - [], - ); + const arrowConfig = { + shouldPreventDefault: false, + }; const arrowLeftCallback = (e?: GestureResponderEvent | KeyboardEvent) => { if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0) { @@ -664,41 +658,27 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig); - const handleFocusOnBackspace = useCallback( - (e: NativeSyntheticEvent) => { - if (e.nativeEvent.key !== 'Backspace') { - return; - } - if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0 && selectionMinute.end === 0) { - e.preventDefault(); - focusHourInputOnLastCharacter(); - } - if (secondInputRef.current?.isFocused() && selectionSecond.start === 0 && selectionSecond.end === 0) { - e.preventDefault(); - focusMinuteInputOnLastCharacter(); - } - if (millisecondInputRef.current?.isFocused() && selectionMillisecond.start === 0 && selectionMillisecond.end === 0) { - e.preventDefault(); - focusSecondInputOnLastCharacter(); - } - }, - - [ - selectionMinute.start, - selectionMinute.end, - selectionSecond.start, - selectionSecond.end, - selectionMillisecond.start, - selectionMillisecond.end, - focusHourInputOnLastCharacter, - focusMinuteInputOnLastCharacter, - focusSecondInputOnLastCharacter, - ], - ); + const handleFocusOnBackspace = (e: NativeSyntheticEvent) => { + if (e.nativeEvent.key !== 'Backspace') { + return; + } + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0 && selectionMinute.end === 0) { + e.preventDefault(); + focusHourInputOnLastCharacter(); + } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 0 && selectionSecond.end === 0) { + e.preventDefault(); + focusMinuteInputOnLastCharacter(); + } + if (millisecondInputRef.current?.isFocused() && selectionMillisecond.start === 0 && selectionMillisecond.end === 0) { + e.preventDefault(); + focusSecondInputOnLastCharacter(); + } + }; const {styleForAM, styleForPM} = StyleUtils.getStatusAMandPMButtonStyle(amPmValue); - const numberPad = useCallback(() => { + const numberPad = () => { if (!canUseTouchScreen) { return null; } @@ -709,7 +689,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou isLongPressDisabled /> ); - }, [canUseTouchScreen, updateAmountNumberPad]); + }; useEffect(() => { onInputChange(showFullFormat ? `${hours}:${minutes}:${seconds}.${milliseconds} ${amPmValue}` : `${hours}:${minutes} ${amPmValue}`); @@ -725,38 +705,35 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou } }; - const renderedAmPmButtons = useMemo( - () => ( - - - - - ), - [styles, styleForAM, styleForPM, translate, setAmPmValue], + const renderedAmPmButtons = ( + + + + ); return ( diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index 2132e97e8f32..03b0011e48f7 100644 --- a/src/components/Tooltip/GenericTooltip.tsx +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -12,7 +12,7 @@ import callOrReturn from '@src/types/utils/callOrReturn'; import type {LayoutRectangle} from 'react-native'; -import React, {memo, useCallback, useEffect, useState} from 'react'; +import React, {memo, useEffect, useState} from 'react'; import {cancelAnimation, useSharedValue, withDelay, withTiming} from 'react-native-reanimated'; import type {GenericTooltipProps} from './types'; @@ -88,7 +88,7 @@ function GenericTooltip({ /** * Display the tooltip in an animation. */ - const showTooltip = useCallback(() => { + const showTooltip = () => { setIsRendered(true); setIsVisible(true); @@ -115,7 +115,7 @@ function GenericTooltip({ ); } TooltipSense.activate(); - }, [animation, isAnimationCanceled, isTooltipSenseInitiator, shouldForceAnimate]); + }; // eslint-disable-next-line rulesdir/prefer-early-return useEffect(() => { @@ -143,7 +143,7 @@ function GenericTooltip({ /** * Hide the tooltip in an animation. */ - const hideTooltip = useCallback(() => { + const hideTooltip = () => { cancelAnimation(animation); if (TooltipSense.isActive() && !isTooltipSenseInitiator.get()) { @@ -155,15 +155,15 @@ function GenericTooltip({ } TooltipSense.deactivate(); setIsVisible(false); - }, [animation, isTooltipSenseInitiator]); + }; - const onPressOverlay = useCallback(() => { + const onPressOverlay = () => { if (!shouldUseOverlay) { return; } setShouldUseOverlay(false); hideTooltip(); - }, [shouldUseOverlay, hideTooltip]); + }; // Skip the tooltip and return the children if the text is empty, we don't have a render function. if (StringUtils.isEmptyString(text) && renderTooltipContent == null) { diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 31ca669a09c5..269529300440 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -28,7 +28,6 @@ import type {SplitExpense} from '@src/types/onyx/IOU'; import type {OnyxCollection} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {useCallback} from 'react'; import type {CurrencyListActionsContextType} from './useCurrencyList'; @@ -111,34 +110,28 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const {isOffline} = useNetwork(); const {formatPhoneNumber} = useLocalize(); - const getSplitExpenseEditTransactionOnDelete = useCallback( - (transactionIDs: string[]): Transaction | undefined => { - if (transactionIDs.length !== 1) { - return undefined; - } + const getSplitExpenseEditTransactionOnDelete = (transactionIDs: string[]): Transaction | undefined => { + if (transactionIDs.length !== 1) { + return undefined; + } - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(0)}`]; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(0)}`]; - if (!transaction) { - return undefined; - } + if (!transaction) { + return undefined; + } - const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.comment?.originalTransactionID}`]; - const isSelfDMSplit = isSelfDM(report) || (!!selfDMReportID && transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID); - const hasMultipleSplits = getChildTransactions(allTransactions, originalTransaction?.transactionID).length > 1; - if (!shouldRedirectDeleteToSplitExpenseEdit(transaction, originalTransaction, isSelfDMSplit) || (!hasMultipleSplits && isPerDiemRequestTransactionUtils(originalTransaction))) { - return undefined; - } + const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.comment?.originalTransactionID}`]; + const isSelfDMSplit = isSelfDM(report) || (!!selfDMReportID && transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID); + const hasMultipleSplits = getChildTransactions(allTransactions, originalTransaction?.transactionID).length > 1; + if (!shouldRedirectDeleteToSplitExpenseEdit(transaction, originalTransaction, isSelfDMSplit) || (!hasMultipleSplits && isPerDiemRequestTransactionUtils(originalTransaction))) { + return undefined; + } - return transaction; - }, - [allTransactions, report, selfDMReportID], - ); + return transaction; + }; - const shouldOpenSplitExpenseEditFlowOnDelete = useCallback( - (transactionIDs: string[]): boolean => !!getSplitExpenseEditTransactionOnDelete(transactionIDs), - [getSplitExpenseEditTransactionOnDelete], - ); + const shouldOpenSplitExpenseEditFlowOnDelete = (transactionIDs: string[]): boolean => !!getSplitExpenseEditTransactionOnDelete(transactionIDs); /** * Delete transactions by IDs @@ -149,311 +142,273 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac * @param isSingleTransactionView - Optional flag indicating if the deletion is from a single transaction view * @returns Result describing whether the delete redirected or deleted transaction threads */ - const deleteTransactions = useCallback( - ( - transactionIDs: string[], - duplicateTransactions: OnyxCollection, - duplicateTransactionViolations: OnyxCollection, - currentSearchHash?: number, - isSingleTransactionView?: boolean, - ): DeleteTransactionsResult => { - if (!transactionIDs.length) { - return { - action: 'deleted', - deletedTransactionThreadReportIDs: [], - }; - } - - const splitExpenseEditTransaction = getSplitExpenseEditTransactionOnDelete(transactionIDs); - - if (splitExpenseEditTransaction) { - const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpenseEditTransaction.reportID}`]; - const selfDMReport = isSelfDM(report) ? report : allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`]; - const splitExpenseEditTransactionReport = transactionReport ?? selfDMReport; - // We intentionally don't pass `searchSnapshotPolicy` here (unlike the on-page split flow in - // `useSplitEffectivePolicy`). This preserves the pre-refactor behavior: `initSplitExpense` resolved - // the policy from the report policy and the transaction's customUnit only, never from a search-results - // snapshot. The snapshot-aware branch lives on the split pages, where the mileage rate is rendered. - const splitEffectivePolicy = getSplitEffectivePolicy({ - policy, - transaction: splitExpenseEditTransaction, - policyForCustomUnit: findSplitPolicyForCustomUnit(allPolicies, splitExpenseEditTransaction), - fallbackPolicy: policyForMovingExpenses, - }); - initSplitExpense( - splitExpenseEditTransaction, - splitExpenseEditTransactionReport, - splitEffectivePolicy, - selfDMReportID, - restrictedActionPolicyID, - personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - { - navigateToEditSplitExpense: true, - }, - ); - return { - action: 'redirected', - }; - } - - const iouActions = reportActions.filter((action) => isMoneyRequestAction(action)); - - const transactionsWithActions = transactionIDs.map((transactionID) => ({ - transactionID, - transaction: allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`], - action: iouActions.find((action) => { - const IOUTransactionID = getOriginalMessage(action)?.IOUTransactionID; - return transactionID === IOUTransactionID; - }), - })); - const deletedTransactionIDs: string[] = []; - const deletedTransactionThreadReportIDs = new Set(); - const {splitTransactionsByOriginalTransactionID, nonSplitTransactions} = transactionsWithActions.reduce( - (acc, item) => { - const {transaction} = item; - const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.comment?.originalTransactionID}`]; - const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); - const originalTransactionID = transaction?.comment?.originalTransactionID; - - const transactionCurrentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; - const isMovedExpenseSplitChild = - !isExpenseSplit && !!originalTransactionID && isSplitChildTransaction(transaction) && !originalTransaction?.comment?.splits && isIOUReport(transactionCurrentReport); - - if ((isExpenseSplit || isMovedExpenseSplitChild) && originalTransactionID) { - if (acc.splitTransactionsByOriginalTransactionID[originalTransactionID] == null) { - acc.splitTransactionsByOriginalTransactionID[originalTransactionID] = []; - } - acc.splitTransactionsByOriginalTransactionID[originalTransactionID].push(item); - } else { - acc.nonSplitTransactions.push(item); - } - - return acc; - }, - {splitTransactionsByOriginalTransactionID: {}, nonSplitTransactions: []} as { - splitTransactionsByOriginalTransactionID: Record; - nonSplitTransactions: TransactionWithAction[]; + const deleteTransactions = ( + transactionIDs: string[], + duplicateTransactions: OnyxCollection, + duplicateTransactionViolations: OnyxCollection, + currentSearchHash?: number, + isSingleTransactionView?: boolean, + ): DeleteTransactionsResult => { + if (!transactionIDs.length) { + return { + action: 'deleted', + deletedTransactionThreadReportIDs: [], + }; + } + + const splitExpenseEditTransaction = getSplitExpenseEditTransactionOnDelete(transactionIDs); + + if (splitExpenseEditTransaction) { + const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpenseEditTransaction.reportID}`]; + const selfDMReport = isSelfDM(report) ? report : allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`]; + const splitExpenseEditTransactionReport = transactionReport ?? selfDMReport; + // We intentionally don't pass `searchSnapshotPolicy` here (unlike the on-page split flow in + // `useSplitEffectivePolicy`). This preserves the pre-refactor behavior: `initSplitExpense` resolved + // the policy from the report policy and the transaction's customUnit only, never from a search-results + // snapshot. The snapshot-aware branch lives on the split pages, where the mileage rate is rendered. + const splitEffectivePolicy = getSplitEffectivePolicy({ + policy, + transaction: splitExpenseEditTransaction, + policyForCustomUnit: findSplitPolicyForCustomUnit(allPolicies, splitExpenseEditTransaction), + fallbackPolicy: policyForMovingExpenses, + }); + initSplitExpense( + splitExpenseEditTransaction, + splitExpenseEditTransactionReport, + splitEffectivePolicy, + selfDMReportID, + restrictedActionPolicyID, + personalPolicy?.outputCurrency, + getCurrencyDecimals, + getCurrencySymbol, + { + navigateToEditSplitExpense: true, }, ); - - for (const transactionID of Object.keys(splitTransactionsByOriginalTransactionID)) { - const splitIDs = new Set((splitTransactionsByOriginalTransactionID[transactionID] ?? []).map((transaction) => transaction.transactionID)); - const allChildTransactions = getChildTransactions(allTransactions, transactionID); - const childTransactions = allChildTransactions.filter((transaction) => !splitIDs.has(transaction?.transactionID ?? String(CONST.DEFAULT_NUMBER_ID))); - - if (childTransactions.length === 0) { - nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]); - continue; - } - const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const originalTransactionIouActions = getIOUActionForTransactions([transactionID], allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`]); - const iouReportID = isMoneyRequestAction(originalTransactionIouActions.at(0)) ? originalTransactionIouActions.at(0)?.reportID : undefined; - const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; - // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. - const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) ? candidateIOUReport : undefined; - const splitExpensesTotal = allChildTransactions.reduce((total, childTransaction) => { - const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; - return total + initSplitExpenseItemData(childTransaction, transactionReport, {getCurrencyDecimals}).amount; - }, 0); - const policyRecentlyUsedCategories = - allPolicyRecentlyUsedCategories?.[ - `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(getIOURequestPolicyID(originalTransaction, report))}` - ] ?? []; - - const hasEditableSplitExpensesLeft = childTransactions.some((childTransaction) => { - const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; - return (currentReport?.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED; - }); - - if (!hasEditableSplitExpensesLeft) { - nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]); - continue; + return { + action: 'redirected', + }; + } + + const iouActions = reportActions.filter((action) => isMoneyRequestAction(action)); + + const transactionsWithActions = transactionIDs.map((transactionID) => ({ + transactionID, + transaction: allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`], + action: iouActions.find((action) => { + const IOUTransactionID = getOriginalMessage(action)?.IOUTransactionID; + return transactionID === IOUTransactionID; + }), + })); + const deletedTransactionIDs: string[] = []; + const deletedTransactionThreadReportIDs = new Set(); + const {splitTransactionsByOriginalTransactionID, nonSplitTransactions} = transactionsWithActions.reduce( + (acc, item) => { + const {transaction} = item; + const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.comment?.originalTransactionID}`]; + const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); + const originalTransactionID = transaction?.comment?.originalTransactionID; + + const transactionCurrentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; + const isMovedExpenseSplitChild = + !isExpenseSplit && !!originalTransactionID && isSplitChildTransaction(transaction) && !originalTransaction?.comment?.splits && isIOUReport(transactionCurrentReport); + + if ((isExpenseSplit || isMovedExpenseSplitChild) && originalTransactionID) { + if (acc.splitTransactionsByOriginalTransactionID[originalTransactionID] == null) { + acc.splitTransactionsByOriginalTransactionID[originalTransactionID] = []; + } + acc.splitTransactionsByOriginalTransactionID[originalTransactionID].push(item); + } else { + acc.nonSplitTransactions.push(item); } - const remainingSplitExpenses = childTransactions.map((childTransaction) => { - // For selfDM splits, childTransaction.reportID is UNREPORTED_REPORT_ID ('0'), which does not map to a - // real report. Resolve it to the actual selfDM report ID (same approach as initSplitExpense) so that - // updateSplitTransactions can route the restored transaction to the correct report instead of ending - // up with an undefined reportID downstream. - const resolvedReportID = childTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? selfDMReportID : childTransaction?.reportID; - const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${resolvedReportID}`]; - return initSplitExpenseItemData(childTransaction, transactionReport, {reportID: resolvedReportID, getCurrencyDecimals}); - }); - const remainingSplitExpensesTotal = remainingSplitExpenses.reduce((total, splitExpense) => total + splitExpense.amount, 0); - const updatedRemainingSplitExpenses = - originalTransaction && isPerDiemRequestTransactionUtils(originalTransaction) && remainingSplitExpenses.length > 0 && remainingSplitExpensesTotal !== splitExpensesTotal - ? redistributeRemainingPerDiemSplitExpenses(remainingSplitExpenses, splitExpensesTotal, originalTransaction.currency ?? CONST.CURRENCY.USD, getCurrencyDecimals) - : remainingSplitExpenses; - - const parentTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; - const expenseReport = report?.type === CONST.REPORT.TYPE.EXPENSE ? report : parentTransactionReport; - const activeGroupSearchHashes = - currentSearchHash !== undefined && currentSearchHash >= 0 ? getActiveGroupSearchHashes(currentSearchResults?.data, currentSearchQueryJSON) : []; - - updateSplitTransactions({ - getCurrencyDecimals, - getCurrencySymbol, - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportActionsList: allReportActions, - allReportNameValuePairsList: allReportNameValuePairs, - allSnapshots, - allPolicyTags, - transactionData: { - reportID: report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: transactionID, - splitExpenses: updatedRemainingSplitExpenses, - splitExpensesTotal, - }, - searchContext: { - currentSearchHash, - activeGroupSearchHashes, - }, - policyCategories, - policy, - policyRecentlyUsedCategories, - iouReport, - firstIOU: originalTransactionIouActions.at(0), - isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), - currentUserPersonalDetails, - transactionViolations, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - quickAction, - betas, - personalDetails, - transactionReport: report, - expenseReport, - isOffline, - delegateAccountID, - isTrackIntentUser, - formatPhoneNumber, - }); + return acc; + }, + {splitTransactionsByOriginalTransactionID: {}, nonSplitTransactions: []} as { + splitTransactionsByOriginalTransactionID: Record; + nonSplitTransactions: TransactionWithAction[]; + }, + ); + + for (const transactionID of Object.keys(splitTransactionsByOriginalTransactionID)) { + const splitIDs = new Set((splitTransactionsByOriginalTransactionID[transactionID] ?? []).map((transaction) => transaction.transactionID)); + const allChildTransactions = getChildTransactions(allTransactions, transactionID); + const childTransactions = allChildTransactions.filter((transaction) => !splitIDs.has(transaction?.transactionID ?? String(CONST.DEFAULT_NUMBER_ID))); + + if (childTransactions.length === 0) { + nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]); + continue; + } + const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const originalTransactionIouActions = getIOUActionForTransactions([transactionID], allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`]); + const iouReportID = isMoneyRequestAction(originalTransactionIouActions.at(0)) ? originalTransactionIouActions.at(0)?.reportID : undefined; + const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; + // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. + const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) ? candidateIOUReport : undefined; + const splitExpensesTotal = allChildTransactions.reduce((total, childTransaction) => { + const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; + return total + initSplitExpenseItemData(childTransaction, transactionReport, {getCurrencyDecimals}).amount; + }, 0); + const policyRecentlyUsedCategories = + allPolicyRecentlyUsedCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(getIOURequestPolicyID(originalTransaction, report))}`] ?? + []; + + const hasEditableSplitExpensesLeft = childTransactions.some((childTransaction) => { + const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; + return (currentReport?.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED; + }); + + if (!hasEditableSplitExpensesLeft) { + nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]); + continue; } - for (const {transactionID, action} of nonSplitTransactions) { - if (!action) { - continue; - } - const iouReportID = isMoneyRequestAction(action) ? action?.reportID : undefined; - const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; - // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. - // Invoice reports also carry the money request action; without them the optimistic delete would run with an - // undefined iouReport, so the invoice/preview would not be marked deleted (visible until a server response, - // or indefinitely offline). Its chatReportID points to the invoice room. See issue #97399. - const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) || isInvoiceReport(candidateIOUReport) ? candidateIOUReport : undefined; - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`]; - const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`]; - - // A self-DM expense is a tracked expense: it has no IOU report to key the cleanup on, so it goes - // through the track-expense flow, which resolves the self-DM report actions and the whisper. - if (isSelfDM(candidateIOUReport) && isTrackExpenseAction(action)) { - // The Onyx collection can be missing the self-DM actions when the delete comes from Search, where - // they are read from the search snapshot instead, so the ones passed in fill the gaps. Actionable - // track expense whispers are matched on their own since they are built without a `reportID`. - const selfDMReportActions: ReportActions = { - ...Object.fromEntries( - reportActions - .filter((chatAction) => chatAction.reportID === candidateIOUReport?.reportID || isActionableTrackExpense(chatAction)) - .map((chatAction) => [chatAction.reportActionID, chatAction]), - ), - ...allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${candidateIOUReport?.reportID}`], - }; - - deleteTrackExpense({ - chatReportID: candidateIOUReport?.reportID, - chatReport: candidateIOUReport, - chatReportActions: selfDMReportActions, - transactionID, - reportAction: action, - iouReport: undefined, - chatIOUReport: undefined, - transactions: duplicateTransactions, - violations: duplicateTransactionViolations, - isSingleTransactionView, - isChatReportArchived: isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${candidateIOUReport?.reportID}`]), - isChatIOUReportArchived: false, - allTransactionViolationsParam: transactionViolations, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', - policy: undefined, - getCurrencyDecimals, - }); - deletedTransactionIDs.push(transactionID); - if (action.childReportID) { - deletedTransactionThreadReportIDs.add(action.childReportID); - } - continue; - } + const remainingSplitExpenses = childTransactions.map((childTransaction) => { + // For selfDM splits, childTransaction.reportID is UNREPORTED_REPORT_ID ('0'), which does not map to a + // real report. Resolve it to the actual selfDM report ID (same approach as initSplitExpense) so that + // updateSplitTransactions can route the restored transaction to the correct report instead of ending + // up with an undefined reportID downstream. + const resolvedReportID = childTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? selfDMReportID : childTransaction?.reportID; + const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${resolvedReportID}`]; + return initSplitExpenseItemData(childTransaction, transactionReport, {reportID: resolvedReportID, getCurrencyDecimals}); + }); + const remainingSplitExpensesTotal = remainingSplitExpenses.reduce((total, splitExpense) => total + splitExpense.amount, 0); + const updatedRemainingSplitExpenses = + originalTransaction && isPerDiemRequestTransactionUtils(originalTransaction) && remainingSplitExpenses.length > 0 && remainingSplitExpensesTotal !== splitExpensesTotal + ? redistributeRemainingPerDiemSplitExpenses(remainingSplitExpenses, splitExpensesTotal, originalTransaction.currency ?? CONST.CURRENCY.USD, getCurrencyDecimals) + : remainingSplitExpenses; + + const parentTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + const expenseReport = report?.type === CONST.REPORT.TYPE.EXPENSE ? report : parentTransactionReport; + const activeGroupSearchHashes = currentSearchHash !== undefined && currentSearchHash >= 0 ? getActiveGroupSearchHashes(currentSearchResults?.data, currentSearchQueryJSON) : []; + + updateSplitTransactions({ + getCurrencyDecimals, + getCurrencySymbol, + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + allSnapshots, + allPolicyTags, + transactionData: { + reportID: report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: transactionID, + splitExpenses: updatedRemainingSplitExpenses, + splitExpensesTotal, + }, + searchContext: { + currentSearchHash, + activeGroupSearchHashes, + }, + policyCategories, + policy, + policyRecentlyUsedCategories, + iouReport, + firstIOU: originalTransactionIouActions.at(0), + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + currentUserPersonalDetails, + transactionViolations, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + quickAction, + betas, + personalDetails, + transactionReport: report, + expenseReport, + isOffline, + delegateAccountID, + isTrackIntentUser, + formatPhoneNumber, + }); + } + + for (const {transactionID, action} of nonSplitTransactions) { + if (!action) { + continue; + } + const iouReportID = isMoneyRequestAction(action) ? action?.reportID : undefined; + const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; + // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. + // Invoice reports also carry the money request action; without them the optimistic delete would run with an + // undefined iouReport, so the invoice/preview would not be marked deleted (visible until a server response, + // or indefinitely offline). Its chatReportID points to the invoice room. See issue #97399. + const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) || isInvoiceReport(candidateIOUReport) ? candidateIOUReport : undefined; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`]; + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`]; + + // A self-DM expense is a tracked expense: it has no IOU report to key the cleanup on, so it goes + // through the track-expense flow, which resolves the self-DM report actions and the whisper. + if (isSelfDM(candidateIOUReport) && isTrackExpenseAction(action)) { + // The Onyx collection can be missing the self-DM actions when the delete comes from Search, where + // they are read from the search snapshot instead, so the ones passed in fill the gaps. Actionable + // track expense whispers are matched on their own since they are built without a `reportID`. + const selfDMReportActions: ReportActions = { + ...Object.fromEntries( + reportActions + .filter((chatAction) => chatAction.reportID === candidateIOUReport?.reportID || isActionableTrackExpense(chatAction)) + .map((chatAction) => [chatAction.reportActionID, chatAction]), + ), + ...allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${candidateIOUReport?.reportID}`], + }; - const chatIOUReportID = chatReport?.reportID; - const isChatIOUReportArchived = isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${chatIOUReportID}`]); - const iouPolicy = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined; - deleteMoneyRequest({ + deleteTrackExpense({ + chatReportID: candidateIOUReport?.reportID, + chatReport: candidateIOUReport, + chatReportActions: selfDMReportActions, transactionID, reportAction: action, - transactionThreadReport, + iouReport: undefined, + chatIOUReport: undefined, transactions: duplicateTransactions, violations: duplicateTransactionViolations, - iouReport, - chatReport, - isChatIOUReportArchived, isSingleTransactionView, - transactionIDsPendingDeletion: deletedTransactionIDs, - selectedTransactionIDs: transactionIDs, + isChatReportArchived: isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${candidateIOUReport?.reportID}`]), + isChatIOUReportArchived: false, allTransactionViolationsParam: transactionViolations, currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', - policy: iouPolicy, + policy: undefined, getCurrencyDecimals, }); deletedTransactionIDs.push(transactionID); if (action.childReportID) { deletedTransactionThreadReportIDs.add(action.childReportID); } + continue; } - return { - action: 'deleted', - deletedTransactionThreadReportIDs: Array.from(deletedTransactionThreadReportIDs), - }; - }, - [ - allPolicyRecentlyUsedCategories, - allReportNameValuePairs, - allReports, - allReportActions, - allSnapshots, - allTransactions, - currentUserPersonalDetails, - currentSearchQueryJSON, - currentSearchResults?.data, - isBetaEnabled, - policy, - policyCategories, - policyRecentlyUsedCurrencies, - quickAction, - report, - reportActions, - transactionViolations, - betas, - allPolicyTags, - personalDetails, - selfDMReportID, - allPolicies, - policyForMovingExpenses, - restrictedActionPolicyID, - getSplitExpenseEditTransactionOnDelete, - isOffline, - personalPolicy?.outputCurrency, - delegateAccountID, - isTrackIntentUser, - formatPhoneNumber, - getCurrencyDecimals, - getCurrencySymbol, - ], - ); + const chatIOUReportID = chatReport?.reportID; + const isChatIOUReportArchived = isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${chatIOUReportID}`]); + const iouPolicy = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined; + deleteMoneyRequest({ + transactionID, + reportAction: action, + transactionThreadReport, + transactions: duplicateTransactions, + violations: duplicateTransactionViolations, + iouReport, + chatReport, + isChatIOUReportArchived, + isSingleTransactionView, + transactionIDsPendingDeletion: deletedTransactionIDs, + selectedTransactionIDs: transactionIDs, + allTransactionViolationsParam: transactionViolations, + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.email ?? '', + policy: iouPolicy, + getCurrencyDecimals, + }); + deletedTransactionIDs.push(transactionID); + if (action.childReportID) { + deletedTransactionThreadReportIDs.add(action.childReportID); + } + } + + return { + action: 'deleted', + deletedTransactionThreadReportIDs: Array.from(deletedTransactionThreadReportIDs), + }; + }; return { deleteTransactions, diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 822edcbdbd57..b101b326cdae 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -31,7 +31,7 @@ import type {PersonalDetailsList, ReportAction, Session} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type DebugReportActionCreatePageProps = PlatformStackScreenProps; @@ -73,13 +73,13 @@ function DebugReportActionCreatePage({ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); - const reportAction = useMemo(() => parseReportActionJSON(draftReportAction), [draftReportAction]); + const reportAction = parseReportActionJSON(draftReportAction); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); const [error, setError] = useState(); - const createReportAction = useCallback(() => { + const createReportAction = () => { if (!reportAction) { return; } @@ -87,21 +87,18 @@ function DebugReportActionCreatePage({ [reportAction.reportActionID]: reportAction, }); Navigation.navigate(ROUTES.DEBUG_REPORT_TAB_ACTIONS.getRoute(reportID)); - }, [reportAction, reportID]); - - const editJSON = useCallback( - (updatedJSON: string) => { - try { - DebugUtils.validateReportActionJSON(updatedJSON); - setError(''); - } catch (e) { - const {cause, message} = e as SyntaxError; - setError(cause ? translate(message as TranslationPaths, cause as never) : message); - } - setDraftReportAction(updatedJSON); - }, - [translate], - ); + }; + + const editJSON = (updatedJSON: string) => { + try { + DebugUtils.validateReportActionJSON(updatedJSON); + setError(''); + } catch (e) { + const {cause, message} = e as SyntaxError; + setError(cause ? translate(message as TranslationPaths, cause as never) : message); + } + setDraftReportAction(updatedJSON); + }; return ( ; @@ -75,25 +75,22 @@ function DebugTransactionViolationCreatePage({ const [draftTransactionViolation, setDraftTransactionViolation] = useState(() => getInitialTransactionViolation()); const [error, setError] = useState(); - const editJSON = useCallback( - (updatedJSON: string) => { - try { - DebugUtils.validateTransactionViolationJSON(updatedJSON); - setError(''); - } catch (e) { - const {cause, message} = e as SyntaxError; - setError(cause ? translate(message as TranslationPaths, cause as never) : message); - } - setDraftTransactionViolation(updatedJSON); - }, - [translate], - ); + const editJSON = (updatedJSON: string) => { + try { + DebugUtils.validateTransactionViolationJSON(updatedJSON); + setError(''); + } catch (e) { + const {cause, message} = e as SyntaxError; + setError(cause ? translate(message as TranslationPaths, cause as never) : message); + } + setDraftTransactionViolation(updatedJSON); + }; - const createTransactionViolation = useCallback(() => { + const createTransactionViolation = () => { const parsedTransactionViolation = DebugUtils.stringToOnyxData(draftTransactionViolation, 'object') as TransactionViolation; Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [...(transactionViolations ?? []), parsedTransactionViolation]); Navigation.navigate(ROUTES.DEBUG_TRANSACTION_TAB_VIOLATIONS.getRoute(transactionID)); - }, [draftTransactionViolation, transactionID, transactionViolations]); + }; if (!transactionID) { return ; diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index e668a943d263..4d3a2978b788 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -149,7 +149,7 @@ import {delegateEmailSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {WithReportOrNotFoundProps} from './inbox/report/withReportOrNotFound'; @@ -227,7 +227,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const {removeTransaction} = useSearchSelectionActions(); - const transactionThreadReportID = useMemo(() => getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline), [reportActions, isOffline, report, chatReport]); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -247,62 +247,53 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const currentUserDisplayName = currentUserPersonalDetails?.displayName; const currentUserLocalCurrencyCode = currentUserPersonalDetails?.localCurrencyCode; const {getCurrencyDecimals} = useCurrencyListActions(); - const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserEmail), [currentUserEmail]); + const filteredPoliciesInfoSelector = createFilteredPoliciesInfoSelector(currentUserEmail); const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); const {showConfirmModal} = useConfirmModal(); - const reportForHeader = useMemo(() => getReportForHeader(report, parentReport), [report, parentReport]); + const reportForHeader = getReportForHeader(report, parentReport); const derivedReportNames = useDerivedReportNamesByReportIDs([report?.parentReportID, reportForHeader?.reportID]); const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); const derivedHeaderReportName = getReportNameFromNames(derivedReportNames, reportForHeader?.reportID); - const isPolicyAdmin = useMemo(() => isPolicyAdminUtil(policy), [policy]); + const isPolicyAdmin = isPolicyAdminUtil(policy); const isPolicyEmployee = isPolicyEmployeeUtil(report?.policyID, policy); - const isPolicyExpenseChat = useMemo(() => isPolicyExpenseChatUtil(report), [report]); - const isChatRoom = useMemo(() => isChatRoomUtil(report), [report]); - const isUserCreatedPolicyRoom = useMemo(() => isUserCreatedPolicyRoomUtil(report), [report]); - const isDefaultRoom = useMemo(() => isDefaultRoomUtil(report), [report]); - const isChatThread = useMemo(() => isChatThreadUtil(report), [report]); - const isMoneyRequestReport = useMemo(() => isMoneyRequestReportUtil(report), [report]); - const isMoneyRequest = useMemo(() => isMoneyRequestUtil(report), [report]); - const isInvoiceReport = useMemo(() => isInvoiceReportUtil(report), [report]); - const isFinancialReportsForBusinesses = useMemo(() => isFinancialReportsForBusinessesUtil(report), [report]); - const isInvoiceRoom = useMemo(() => isInvoiceRoomUtil(report), [report]); - const isTaskReport = useMemo(() => isTaskReportUtil(report), [report]); - const isSelfDM = useMemo(() => isSelfDMUtil(report), [report]); - const isTrackExpenseReport = useMemo(() => isTrackExpenseReportUtil(report, parentReport, parentReportAction), [report, parentReport, parentReportAction]); + const isPolicyExpenseChat = isPolicyExpenseChatUtil(report); + const isChatRoom = isChatRoomUtil(report); + const isUserCreatedPolicyRoom = isUserCreatedPolicyRoomUtil(report); + const isDefaultRoom = isDefaultRoomUtil(report); + const isChatThread = isChatThreadUtil(report); + const isMoneyRequestReport = isMoneyRequestReportUtil(report); + const isMoneyRequest = isMoneyRequestUtil(report); + const isInvoiceReport = isInvoiceReportUtil(report); + const isFinancialReportsForBusinesses = isFinancialReportsForBusinessesUtil(report); + const isInvoiceRoom = isInvoiceRoomUtil(report); + const isTaskReport = isTaskReportUtil(report); + const isSelfDM = isSelfDMUtil(report); + const isTrackExpenseReport = isTrackExpenseReportUtil(report, parentReport, parentReportAction); const isCanceledTaskReport = isCanceledTaskReportUtil(report, parentReportAction); const isParentReportArchived = useReportIsArchived(parentReport?.reportID); const isTaskModifiable = canModifyTask(report, currentUserAccountID, isParentReportArchived); const isTaskActionable = canActionTask(report, parentReportAction, currentUserAccountID, parentReport, isParentReportArchived); - const canEditReportDescription = useMemo(() => canEditReportDescriptionUtil(report, policy), [report, policy]); + const canEditReportDescription = canEditReportDescriptionUtil(report, policy); const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== '') && (isTaskReport ? isTaskModifiable : true); const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; const isSingleTransactionView = isMoneyRequest || isTrackExpenseReport; const isSelfDMTrackExpenseReport = isTrackExpenseReport && isSelfDMUtil(parentReport); const isReportArchived = useReportIsArchived(report?.reportID); - const isArchivedRoom = useMemo(() => isArchivedNonExpenseReport(report, isReportArchived), [report, isReportArchived]); - const shouldDisableRename = useMemo(() => shouldDisableRenameUtil(report, isReportArchived), [report, isReportArchived]); + const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived); + const shouldDisableRename = shouldDisableRenameUtil(report, isReportArchived); const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, derivedParentReportName, isParentReportArchived); const base62ReportID = getBase62ReportID(Number(report.reportID)); const ancestors = useAncestors(report); - const chatRoomSubtitle = useMemo(() => { - const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, false, isReportArchived); + const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, false, isReportArchived); + const chatRoomSubtitle = subtitle || ''; - if (subtitle) { - return subtitle; - } - - return ''; - }, [isReportArchived, report, policy, conciergeReportID, translate]); - - const isSystemChat = useMemo(() => isSystemChatUtil(report), [report]); - const isGroupChat = useMemo(() => isGroupChatUtil(report), [report]); - const isRootGroupChat = useMemo(() => isRootGroupChatUtil(report, isReportArchived), [report, isReportArchived]); - const isThread = useMemo(() => isThreadUtil(report), [report]); + const isSystemChat = isSystemChatUtil(report); + const isGroupChat = isGroupChatUtil(report); + const isRootGroupChat = isRootGroupChatUtil(report, isReportArchived); + const isThread = isThreadUtil(report); const shouldOpenRoomMembersPage = isUserCreatedPolicyRoom || isChatThread || (isPolicyExpenseChat && isPolicyAdmin); - const participants = useMemo(() => { - return getParticipantsList(report, personalDetails, shouldOpenRoomMembersPage); - }, [report, personalDetails, shouldOpenRoomMembersPage]); + const participants = getParticipantsList(report, personalDetails, shouldOpenRoomMembersPage); let caseID: CaseID; if (isMoneyRequestReport || isInvoiceReport) { @@ -337,12 +328,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report typeof requestParentReportAction?.actorAccountID === 'number' && typeof currentUserAccountID === 'number' && requestParentReportAction.actorAccountID === currentUserAccountID; const isDeletedParentAction = isDeletedAction(requestParentReportAction); - const moneyRequestReport: OnyxEntry = useMemo(() => { - if (caseID === CASES.MONEY_REQUEST) { - return parentReport; - } - return report; - }, [caseID, parentReport, report]); + const moneyRequestReport: OnyxEntry = caseID === CASES.MONEY_REQUEST ? parentReport : report; const isMoneyRequestReportArchived = useReportIsArchived(moneyRequestReport?.reportID); const [moneyRequestReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); @@ -358,7 +344,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); const [iouOriginalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransaction?.comment?.originalTransactionID)}`); - const hasWorkspaceToSubmitToSelector = useMemo(() => createHasWorkspaceToSubmitToSelector(currentUserLogin), [currentUserLogin]); + const hasWorkspaceToSubmitToSelector = createHasWorkspaceToSubmitToSelector(currentUserLogin); const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({ @@ -375,7 +361,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report } else if (caseID === CASES.DEFAULT) { deleteMenuItemTitle = translate('common.delete'); } - const isWorkspaceChat = useMemo(() => isWorkspaceChatUtil(report?.chatType ?? ''), [report?.chatType]); + const isWorkspaceChat = isWorkspaceChatUtil(report?.chatType ?? ''); useEffect(() => { // Do not fetch private notes if the feature is disabled, isLoadingPrivateNotes is already defined, the network is offline, or if the report is a self DM. @@ -386,7 +372,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report getReportPrivateNote(report?.reportID); }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); - const leaveChat = useCallback(() => { + const leaveChat = () => { if (isRootGroupChat) { leaveGroupChat(report, quickAction?.chatReportID?.toString() === report.reportID, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, betas); return; @@ -394,9 +380,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin); leaveRoom(report, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom); - }, [isRootGroupChat, isPolicyEmployee, isPolicyAdmin, quickAction?.chatReportID, report, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, betas]); + }; - const showLastMemberLeavingModal = useCallback(async () => { + const showLastMemberLeavingModal = async () => { const {action} = await showConfirmModal({ title: translate('groupChat.lastMemberTitle'), prompt: translate('groupChat.lastMemberWarning'), @@ -409,7 +395,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report return; } leaveChat(); - }, [showConfirmModal, translate, leaveChat]); + }; const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserAccountID, !!reportNameValuePairs?.private_isArchived); @@ -440,7 +426,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const shouldShowWriteCapability = !isMoneyRequestReport; const shouldShowMenuItem = shouldShowNotificationPref || shouldShowWriteCapability || (!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE); - const menuItems: DynamicReportDetailsPageMenuItem[] = useMemo(() => { + const menuItems: DynamicReportDetailsPageMenuItem[] = (() => { const items: DynamicReportDetailsPageMenuItem[] = []; if (isSelfDM) { @@ -721,84 +707,11 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report } return items; - }, [ - isSelfDM, - isArchivedRoom, - shouldShowGoToRoom, - isGroupChat, - isDefaultRoom, - isChatThread, - isPolicyEmployee, - isUserCreatedPolicyRoom, - participants.length, - report, - isSystemChat, - activeChatMembers.length, - isPolicyExpenseChat, - shouldShowMenuItem, - isTrackExpenseReport, - isDeletedParentAction, - isMoneyRequestReport, - isInvoiceReport, - isTaskReport, - isCanceledTaskReport, - shouldShowGoToWorkspace, - shouldShowLeaveButton, - isDebugModeEnabled, - expensifyIcons.Users, - expensifyIcons.Gear, - expensifyIcons.Send, - expensifyIcons.Folder, - expensifyIcons.UserPlus, - expensifyIcons.Pencil, - expensifyIcons.Checkmark, - expensifyIcons.Building, - expensifyIcons.Exit, - expensifyIcons.Bug, - expensifyIcons.Hashtag, - styles.ph2, - shouldOpenRoomMembersPage, - navigateBackFromReportDetailsPath, - actionReportID, - actionReportActions, - iouTransactionID, - moneyRequestReport?.reportID, - moneyRequestReportActions, - currentUserAccountID, - currentUserEmail, - currentUserLocalCurrencyCode, - isTaskActionable, - isRootGroupChat, - leaveChat, - showLastMemberLeavingModal, - isSmallScreenWidth, - isRestrictedToPreferredPolicy, - preferredPolicyID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - iouTransaction, - iouOriginalTransaction, - hasWorkspaceToSubmitTo, - filteredPoliciesInfo?.filteredPoliciesCount, - filteredPoliciesInfo?.firstPolicyID, - parentReport, - delegateEmail, - conciergeReportID, - lastWorkspaceNumber, - translate, - currentUserDisplayName, - ]); + })(); - const icons = useMemo( - () => getIcons(report, formatPhoneNumber, translate, personalDetails, null, '', -1, policy, undefined, isReportArchived, pendingDeleteMemberAccountIDs), - [report, formatPhoneNumber, translate, personalDetails, policy, isReportArchived, pendingDeleteMemberAccountIDs], - ); + const icons = getIcons(report, formatPhoneNumber, translate, personalDetails, null, '', -1, policy, undefined, isReportArchived, pendingDeleteMemberAccountIDs); - const renderedAvatar = useMemo(() => { + const renderedAvatar = (() => { if (isChatRoom && !isThread) { return ( @@ -851,26 +764,11 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report style={[styles.w100, styles.mb3]} /> ); - }, [ - isChatRoom, - isThread, - isGroupChat, - icons, - report, - styles.smallEditIconAccount, - styles.mt6, - styles.w100, - styles.mb3, - policy, - participants, - moneyRequestReport?.reportID, - expensifyIcons.Camera, - currentUserAccountID, - ]); + })(); const canJoin = canJoinChat(report, parentReportAction, policy, parentReport, !!reportNameValuePairs?.private_isArchived); - const promotedActions = useMemo(() => { + const promotedActions = (() => { const result: PromotedAction[] = []; if (canJoin) { @@ -884,7 +782,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report result.push(PromotedActions.share()); return result; - }, [canJoin, report, currentUserAccountID]); + })(); const shouldDisplayGroupWorkspaceAsPushRow = !isThread && (isGroupChat || isUserCreatedPolicyRoom || isDefaultRoom); const nameSectionGroupWorkspace = ( @@ -926,10 +824,10 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report ); - const titleField = useMemo((): OnyxTypes.PolicyReportField | undefined => { + const titleField: OnyxTypes.PolicyReportField | undefined = (() => { const fields = getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); return fields.find((reportField) => isReportFieldOfTypeTitle(reportField)); - }, [report, policy?.fieldList]); + })(); const fieldKey = getReportFieldKey(titleField?.fieldID); const isFieldDisabled = isReportFieldDisabled(report, titleField, policy); @@ -990,7 +888,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report ); - const deleteTransaction = useCallback(() => { + const deleteTransaction = () => { if (caseID === CASES.DEFAULT) { deleteTask( report, @@ -1044,42 +942,10 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report } removeTransaction(iouTransactionID); } - }, [ - caseID, - taskDeleteBackTo, - requestParentReportAction, - iouTransaction, - iouOriginalTransaction, - iouTransactionID, - report, - parentReport, - isReportArchived, - currentUserAccountID, - currentUserEmail, - hasOutstandingChildTask, - parentReportAction, - conciergeReportID, - delegateEmail, - ancestors, - reportActionsForOriginalReportID, - moneyRequestReport, - moneyRequestReportActions, - iouReport, - chatIOUReport, - duplicateTransactions, - duplicateTransactionViolations, - isSingleTransactionView, - isMoneyRequestReportArchived, - isChatIOUReportArchived, - allTransactionViolations, - deleteTransactions, - removeTransaction, - iouPolicy, - getCurrencyDecimals, - ]); + }; // Where to navigate back to after deleting the transaction and its report. - const navigateToTargetUrl = useCallback(() => { + const navigateToTargetUrl = () => { if (caseID === CASES.DEFAULT && taskDeleteBackTo) { Navigation.goBack(taskDeleteBackTo); return; @@ -1152,22 +1018,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report setDeleteTransactionNavigateBackUrl(urlToNavigateBack); navigateBackOnDeleteTransaction(urlToNavigateBack as Route); } - }, [ - caseID, - taskDeleteBackTo, - requestParentReportAction, - route.params.reportID, - moneyRequestReport, - iouTransactionID, - iouReport, - chatIOUReport, - isChatIOUReportArchived, - isSingleTransactionView, - requestParentReportActionChildReport, - getCurrencyDecimals, - ]); + }; - const showDeleteModal = useCallback(async () => { + const showDeleteModal = async () => { const deletePrompt = caseID === CASES.DEFAULT ? translate('task.deleteConfirmation') : getDeleteConfirmationPrompt(translate, iouTransaction); const {action} = await showConfirmModal({ title: caseID === CASES.DEFAULT ? translate('task.deleteTask') : getDeleteExpenseTitle(translate, iouTransaction), @@ -1192,9 +1045,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report // "Not Found" flash inside the animating-out panel on slower devices. TransitionTracker.runAfterTransitions({callback: deleteTransaction, waitForUpcomingTransition: true}); }); - }, [showConfirmModal, translate, caseID, iouTransaction, iouTransactionID, shouldOpenSplitExpenseEditFlowOnDelete, navigateToTargetUrl, deleteTransaction]); + }; - const mentionReportContextValue = useMemo(() => ({currentReportID: report.reportID, exactlyMatch: true}), [report.reportID]); + const mentionReportContextValue = {currentReportID: report.reportID, exactlyMatch: true}; const shouldShowFurtherDetailsContent = !isEmptyObject(parentNavigationSubtitleData) && (shouldShowEditableTitleField || isMoneyRequestReport || isInvoiceReport || isMoneyRequest || isTaskReport); diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx index 2a73b4e0f2c9..c7b4912ec667 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx @@ -21,7 +21,7 @@ import type {Policy, ReimbursementAccount} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Enable2FACard from './Enable2FACard'; @@ -89,21 +89,18 @@ function BankAccountValidationForm({requiresTwoFactorAuth, reimbursementAccount, return errors; }; - const submit = useCallback( - (values: FormOnyxValues) => { - const amount1 = filterInput(values.amount1 ?? '', undefined, permittedDecimalSeparator); - const amount2 = filterInput(values.amount2 ?? '', undefined, permittedDecimalSeparator); - const amount3 = filterInput(values.amount3 ?? '', undefined, permittedDecimalSeparator); + const submit = (values: FormOnyxValues) => { + const amount1 = filterInput(values.amount1 ?? '', undefined, permittedDecimalSeparator); + const amount2 = filterInput(values.amount2 ?? '', undefined, permittedDecimalSeparator); + const amount3 = filterInput(values.amount3 ?? '', undefined, permittedDecimalSeparator); - const validateCode = [amount1, amount2, amount3].join(','); + const validateCode = [amount1, amount2, amount3].join(','); - // Send valid amounts to BankAccountAPI::validateBankAccount in Web-Expensify - if (bankAccountID) { - validateBankAccount(bankAccountID, validateCode, policyID); - } - }, - [bankAccountID, policyID, permittedDecimalSeparator], - ); + // Send valid amounts to BankAccountAPI::validateBankAccount in Web-Expensify + if (bankAccountID) { + validateBankAccount(bankAccountID, validateCode, policyID); + } + }; // On android autoCapitalize="words" is necessary when keyboardType="decimal-pad" or inputMode="decimal" to prevent input lag. // See https://github.com/Expensify/App/issues/51868 for more information return ( diff --git a/src/pages/ScheduleCall/ScheduleCallPage.tsx b/src/pages/ScheduleCall/ScheduleCallPage.tsx index 3f8ed94f08b8..46d712a4a8fa 100644 --- a/src/pages/ScheduleCall/ScheduleCallPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallPage.tsx @@ -33,7 +33,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect, useRoute} from '@react-navigation/native'; import {compareAsc, parse} from 'date-fns'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import AvailableBookingDay from './AvailableBookingDay'; @@ -81,11 +81,9 @@ function ScheduleCallPage() { }, [adminsRoomReportID]); // Clear selected time when user comes back to the selection screen - useFocusEffect( - useCallback(() => { - saveBookingDraft({timeSlot: null}); - }, []), - ); + useFocusEffect(() => { + saveBookingDraft({timeSlot: null}); + }); useEffect(() => { return () => { @@ -94,9 +92,9 @@ function ScheduleCallPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const loadTimeSlotsAndSaveDate = useCallback((date: string) => { + const loadTimeSlotsAndSaveDate = (date: string) => { saveBookingDraft({date}); - }, []); + }; const timeSlotDateMap: Record = (() => { if (!calendlySchedule?.data) { diff --git a/src/pages/TransactionDuplicate/ReviewFields.tsx b/src/pages/TransactionDuplicate/ReviewFields.tsx index eacb885e34fc..800474afc365 100644 --- a/src/pages/TransactionDuplicate/ReviewFields.tsx +++ b/src/pages/TransactionDuplicate/ReviewFields.tsx @@ -11,7 +11,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {ReviewDuplicates} from '@src/types/onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type FieldItemType = { @@ -57,16 +57,12 @@ function ReviewFields({stepNames, label, optio return true; }); - const optionRows = useMemo( - () => - filteredOptions?.map((option, idx) => ({ - text: option.text, - keyForList: `${option.text}-${idx}`, - value: option.value, - isSelected: option.value === selectedValue, - })), - [filteredOptions, selectedValue], - ); + const optionRows = filteredOptions?.map((option, idx) => ({ + text: option.text, + keyForList: `${option.text}-${idx}`, + value: option.value, + isSelected: option.value === selectedValue, + })); return ( ) => { - // Prevent opening Report page when clicking LHN row quickly after clicking FAB icon - // or when clicking the active LHN row on large screens - // or when continuously clicking different LHNs, only apply to small screen - // since getTopmostReportId always returns on other devices - const reportActionID = Navigation.getTopmostReportActionId(); - const actionTargetReportActionID = option.actionTargetReportActionID; - - // Prevent opening a new Report page if the user quickly taps on another conversation - // before the first one is displayed. - const shouldBlockReportNavigation = Navigation.getActiveRoute() !== `/${ROUTES.INBOX}` && shouldUseNarrowLayout; - - if ( - (option.reportID === Navigation.getTopmostReportId() && !reportActionID && !actionTargetReportActionID) || - (shouldUseNarrowLayout && isActiveReport(option.reportID) && !reportActionID && !actionTargetReportActionID) || - shouldBlockReportNavigation - ) { - cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${option.reportID}`); - return; - } - // Keep this report visible in the active To-do/Unread tab even after opening it marks it read. - setStickyReportID(option.reportID); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID, actionTargetReportActionID)); - }, - [shouldUseNarrowLayout, isActiveReport, setStickyReportID], - ); + const showReportPage = (option: Report & Pick) => { + // Prevent opening Report page when clicking LHN row quickly after clicking FAB icon + // or when clicking the active LHN row on large screens + // or when continuously clicking different LHNs, only apply to small screen + // since getTopmostReportId always returns on other devices + const reportActionID = Navigation.getTopmostReportActionId(); + const actionTargetReportActionID = option.actionTargetReportActionID; + + // Prevent opening a new Report page if the user quickly taps on another conversation + // before the first one is displayed. + const shouldBlockReportNavigation = Navigation.getActiveRoute() !== `/${ROUTES.INBOX}` && shouldUseNarrowLayout; + + if ( + (option.reportID === Navigation.getTopmostReportId() && !reportActionID && !actionTargetReportActionID) || + (shouldUseNarrowLayout && isActiveReport(option.reportID) && !reportActionID && !actionTargetReportActionID) || + shouldBlockReportNavigation + ) { + cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${option.reportID}`); + return; + } + // Keep this report visible in the active To-do/Unread tab even after opening it marks it read. + setStickyReportID(option.reportID); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID, actionTargetReportActionID)); + }; const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT; diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx index 8967e88d5e65..40a06483855d 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -29,7 +29,7 @@ import ROUTES from '@src/ROUTES'; import type {Rate} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import {getAllValidConnectedIntegration, getWorkflowRules, getWorkspaceRules} from './utils'; @@ -57,15 +57,12 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const customUnitRates: Record = customUnits?.rates ?? {}; const allRates = Object.values(customUnitRates)?.filter((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length ?? 0; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const totalMembersSelector = useMemo( - () => createFilteredMemberCountSelector(policy?.employeeList, policy?.owner, currentUserPersonalDetails.login), - [policy?.employeeList, policy?.owner, currentUserPersonalDetails.login], - ); + const totalMembersSelector = createFilteredMemberCountSelector(policy?.employeeList, policy?.owner, currentUserPersonalDetails.login); const [totalMembers = 0] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { selector: totalMembersSelector, }); // The invoicing company details are provisioned per workspace, so they aren't copied over to the duplicate and shouldn't be advertised here. - const invoiceConfigurationTextSelector = useMemo(() => createInvoiceConfigurationTextSelector(translate, ''), [translate]); + const invoiceConfigurationTextSelector = createInvoiceConfigurationTextSelector(translate, ''); const [invoiceConfigurationText = ''] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, { selector: invoiceConfigurationTextSelector, }); diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index c2a7d368499f..d30c1ee18406 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -63,7 +63,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import UpgradeConfirmation from './UpgradeConfirmation'; import UpgradeIntro from './UpgradeIntro'; @@ -128,7 +128,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { const [priorFirstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); const [priorLastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL); - const ownerPoliciesSelectorWithAccountID = useCallback((policies: OnyxCollection) => ownerPoliciesSelector(policies, accountID), [accountID]); + const ownerPoliciesSelectorWithAccountID = (policies: OnyxCollection) => ownerPoliciesSelector(policies, accountID); const [ownerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: ownerPoliciesSelectorWithAccountID}); const qboConfig = policy?.connections?.quickbooksOnline?.config; const {isOffline} = useNetwork(); From a189df0a09c6b8bd8fd6f7137f38d44767693713 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 00:07:49 +0000 Subject: [PATCH 091/485] Change params instead of pushing when opening a Spend search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar items and saved searches called Navigation.navigate, which pushes another SEARCH.ROOT route — the reason SearchFullscreenNavigator needs a custom state hook to render from the last one onward. That remounts SearchPage, and LayoutAnimationConfig skipEntering then suppresses the layer's entering animation, so the delay that hides the skeleton never applied and it flashed on every sidebar switch. navigateToSpendSearch dispatches PUSH_PARAMS instead, updating the focused route and keeping back-navigation through param changes. It falls back to navigate unless SEARCH.ROOT is focused: the sidebar is ExtraContent for the whole navigator, so it is also on screen over a report, where the params belong to a different route. --- src/libs/SearchNavigationUtils.ts | 28 +++++++++++++++++++++++++++- src/pages/Search/SavedSearchList.tsx | 5 ++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index 925faf6c3590..5cf2b2a2e466 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -1,12 +1,38 @@ +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import {setSearchContext} from './actions/Search'; import Navigation from './Navigation/Navigation'; +import navigationRef from './Navigation/navigationRef'; + +/** + * Opens a Spend search, changing the params of the Search screen already on screen where possible. + * + * `Navigation.navigate` would push another SEARCH.ROOT route — the reason SearchFullscreenNavigator needs a custom + * state hook to render from the last one onward — and that remounts SearchPage, losing anything it was animating. + * PUSH_PARAMS updates the focused route instead and keeps back-navigation through param changes. + * + * It only applies when SEARCH.ROOT is the focused route: the sidebar these calls come from is ExtraContent for the + * whole navigator, so it is also visible over a report, where the params belong to a different route. + */ +function navigateToSpendSearch(query: string, name?: string) { + if (navigationRef.isReady() && navigationRef.getCurrentRoute()?.name === SCREENS.SEARCH.ROOT) { + navigationRef.dispatch({ + type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS, + payload: {params: {q: query, name, rawQuery: undefined}}, + }); + return; + } + + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query, name})); +} function navigateToCannedSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { clearSelectedTransactions(); setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + navigateToSpendSearch(searchQuery); } export default navigateToCannedSpendSearch; +export {navigateToSpendSearch}; diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index a3c5e060fb56..b6af7a0ff33e 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -14,8 +14,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; -import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; +import {navigateToSpendSearch} from '@libs/SearchNavigationUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil, SAVED_SEARCH_FALLBACK_ICON_NAME, SAVED_SEARCH_ICON_NAMES} from '@libs/SearchUIUtils'; @@ -23,7 +23,6 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -62,7 +61,7 @@ function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMen sentryLabel: CONST.SENTRY_LABEL.SEARCH.SAVED_SEARCH_MENU_ITEM, onPress: () => { setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); + navigateToSpendSearch(item?.query ?? '', item?.name); }, rightComponent: ( Date: Wed, 2 Sep 2026 00:29:02 +0000 Subject: [PATCH 092/485] Hold the previous Spend results while a new query loads The results area keyed on the requested query, so a filter change mounted it with no data and showed a skeleton for every query slower than the fade delay. It now keys on the last query that resolved: the previous results stay on screen until the new ones arrive, then the area swaps once. - Only filter refinements hold. A sidebar item or saved search is a different search, so it starts from the skeleton rather than showing rows from the query the user left. SearchQueryRefinement records which query the filter path built, since the query alone can't tell them apart. - MAX_STALE_HOLD_DURATION gives up the held results after 500ms so a slow query still shows a skeleton. - The outgoing layer keeps its exit fade: staying painted while the incoming layer is hidden is what covers the skeleton underneath. - SearchLoadingSkeleton drops its own exit fade. Reanimated detaches an exiting node on web, and that skeleton is the one on screen during a cold start, so navigating straight to Spend tore the tree down mid-animation and React's removeChild threw NotFoundError. --- src/CONST/index.ts | 4 ++ .../Search/SearchLoadingSkeleton.tsx | 9 ++-- .../SearchWithNavigationDeferredMount.tsx | 19 +++++--- .../Search/hooks/useUpdateFilterQuery.tsx | 2 + src/libs/SearchQueryRefinement.ts | 24 ++++++++++ src/pages/Search/SearchPage.tsx | 45 ++++++++++++++++++- src/pages/Search/SearchPageNarrow/index.tsx | 33 +++++++++----- src/pages/Search/SearchPageWide.tsx | 26 +++++++---- 8 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 src/libs/SearchQueryRefinement.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index bc72c1759238..c48e35a84e93 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7803,6 +7803,10 @@ const CONST = { GROUP_PREFIX: 'group_', ANIMATION: { FADE_DURATION: 120, + + // How long the results area keeps showing the previous query's results while a new query loads. Past this + // a slow query gives them up for the skeleton, so the wait is visible. + MAX_STALE_HOLD_DURATION: 500, }, TODO_BADGE_MAX_COUNT: 50, TOP_SEARCH_LIMIT: 10, diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index 77908ab0607c..74f34614373e 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -10,7 +10,8 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import {StyleSheet} from 'react-native'; +import Animated from 'react-native-reanimated'; type SearchLoadingSkeletonProps = { containerStyle?: StyleProp; @@ -20,10 +21,10 @@ function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { const styles = useThemeStyles(); return ( + // The skeleton is absolutely filled so that its exit fade overlays the incoming results instead of + // sharing the parent's column layout with them, which would halve both heights for the fade duration. { endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.COLD); diff --git a/src/components/Search/SearchWithNavigationDeferredMount.tsx b/src/components/Search/SearchWithNavigationDeferredMount.tsx index b265db02c924..76eb379e3863 100644 --- a/src/components/Search/SearchWithNavigationDeferredMount.tsx +++ b/src/components/Search/SearchWithNavigationDeferredMount.tsx @@ -13,6 +13,8 @@ import CONST from '@src/CONST'; import type {ComponentProps} from 'react'; import React from 'react'; +import {StyleSheet} from 'react-native'; +import Animated, {FadeOut} from 'react-native-reanimated'; import Search from './index'; @@ -39,11 +41,18 @@ function SearchWithNavigationDeferredMount(props: ComponentProps) + // Absolutely filled so the exit fade overlays the incoming Search content rather than sharing + // the parent's column layout with it, which would halve both heights for the fade duration. + + + } > diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 1a1e171dc90d..2c93d9e5bc74 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -4,6 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; +import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; @@ -49,6 +50,7 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { return; } + markQueryAsRefinement(queryString); Navigation.setParams({q: queryString, rawQuery: undefined}); } diff --git a/src/libs/SearchQueryRefinement.ts b/src/libs/SearchQueryRefinement.ts new file mode 100644 index 000000000000..2f7d496f5ba7 --- /dev/null +++ b/src/libs/SearchQueryRefinement.ts @@ -0,0 +1,24 @@ +import type {SearchQueryString} from '@components/Search/types'; + +/** + * Records which search query was produced by refining the filters of the list already on screen. + * + * The results area holds the previous query's results through its swap animation so a filter tweak doesn't flash a + * skeleton. That only reads correctly for a refinement: navigating to a sidebar item or a saved search asks for a + * different search, where holding the old results would show rows unrelated to the destination. A query alone can't + * tell the two apart — both arrive as a new `q` on the same screen — so the filter path records its query here. + * + * Only the most recent query is kept: an unknown query is treated as a different search, so a missed mark costs a + * skeleton rather than showing stale results. + */ +let refinedQuery: SearchQueryString | undefined; + +function markQueryAsRefinement(query: SearchQueryString) { + refinedQuery = query; +} + +function isQueryARefinement(query: SearchQueryString | undefined) { + return !!query && refinedQuery === query; +} + +export {markQueryAsRefinement, isQueryARefinement}; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 77a6575c9e0e..f535849c158b 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,6 +1,6 @@ import {ReportSubmitToPopoverHost, SEARCH_REPORT_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; -import type {SearchParams} from '@components/Search/types'; +import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -20,8 +20,10 @@ import {searchInServer} from '@libs/actions/Report'; import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; +import {isQueryARefinement} from '@libs/SearchQueryRefinement'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {hasFilterBarsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; @@ -111,6 +113,43 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(false); }, [currentSearchResults?.isLoading, isSorting, prevIsLoading]); + const [lastResolvedSearch, setLastResolvedSearch] = useState<{queryJSON: SearchQueryJSON; searchResults: SearchResults} | undefined>(undefined); + + // Changing a filter builds a query that has never been cached, so keying the results area on the requested + // query would mount it with no data and flash a skeleton in the middle of the fade. Key it on the last query + // that actually resolved instead: the current results stay on screen until the new ones arrive, then the area + // swaps once. Adjusted during rendering for the same reason as lastNonEmptySearchResults above — the values + // below are consumed in this render, and the reference check bounds the loop to one extra pass. + // isCurrentSearchResolved, not a raw hash comparison: a response folds sort defaults into its own hash, so the + // requested and returned hashes legitimately differ and isSearchDataLoaded is what reconciles them. + const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; + if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { + setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); + } + + // A slow query would otherwise leave the previous results up indefinitely with nothing to show a wait is happening + // (the wide layout has no loading bar). Keyed by hash rather than reset on resolve so the effect never has to call + // setState synchronously; a hash that no longer matches simply stops counting. + const [staleHoldTimedOutHash, setStaleHoldTimedOutHash] = useState(undefined); + const currentQueryHash = currentSearchQueryJSON?.hash; + + useEffect(() => { + if (isSearchResolvedForCurrentQuery || currentQueryHash === undefined) { + return; + } + + const timeoutID = setTimeout(() => setStaleHoldTimedOutHash(currentQueryHash), CONST.SEARCH.ANIMATION.MAX_STALE_HOLD_DURATION); + return () => clearTimeout(timeoutID); + }, [isSearchResolvedForCurrentQuery, currentQueryHash]); + + const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; + + // Only a filter refinement is worth holding for. A sidebar item or saved search is a different search, so its + // results area starts from the skeleton rather than showing rows that belong to the query the user just left. + const shouldHoldLastResolvedSearch = !isSearchResolvedForCurrentQuery && !!lastResolvedSearch && !hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery); + const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; + const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; + const handleSearchAction = useCallback((value: SearchParams | string) => { if (typeof value === 'string') { searchInServer(value); @@ -143,6 +182,8 @@ function SearchPage({route}: SearchPageProps) { void; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ @@ -73,6 +79,8 @@ const tabBarContent = {isInteractive && ( {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A query change remounts this layer, which stays hidden for the delay before fading in. A query that - resolves inside that window swaps straight to its results without showing a skeleton. */} + {/* A resolved query change remounts this layer: the outgoing one fades out and the incoming one waits + for it to finish before fading in. Both layers are absolutely filled so the outgoing fade overlays + the incoming layer instead of sharing the column layout. */} {shouldShowLoadingSkeleton ? ( ) : ( ; + /** The last query whose results resolved. Drives the results area so it holds the current results while a new query loads. */ + contentQueryJSON?: SearchQueryJSON; + + /** Results for `contentQueryJSON`. */ + contentSearchResults: OnyxEntry; + isMobileSelectionModeEnabled: boolean; handleSearchAction: (value: SearchParams | string) => void; onSortPressedCallback: () => void; @@ -51,6 +57,8 @@ type SearchPageWideProps = { function SearchPageWide({ queryJSON, searchResults, + contentQueryJSON, + contentSearchResults, isMobileSelectionModeEnabled, handleSearchAction, onSortPressedCallback, @@ -58,7 +66,7 @@ function SearchPageWide({ searchOverlayContent, onSearchContentReady, }: SearchPageWideProps) { - const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); + const shouldShowLoadingSkeleton = useSearchLoadingState(contentQueryJSON, contentSearchResults); const styles = useThemeStyles(); const {currentSearchKey} = useSearchQueryContext(); const {hasSelectedTransactions} = useSearchSelectionContext(); @@ -114,7 +122,7 @@ function SearchPageWide({ onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - {!!queryJSON && ( + {!!queryJSON && !!contentQueryJSON && ( <> {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A query change remounts this layer, which stays hidden for the delay before fading in. A query that - resolves inside that window swaps straight to its results without showing a skeleton. */} + {/* A resolved query change remounts this layer: the outgoing one fades out and the incoming one waits + for it to finish before fading in. Both layers are absolutely filled so the outgoing fade overlays + the incoming layer instead of sharing the column layout. */} {shouldShowLoadingSkeleton ? ( ) : ( Date: Wed, 2 Sep 2026 00:48:22 +0000 Subject: [PATCH 093/485] Docs: update New Expensify Vacation delegate article to match current UI Co-authored-by: Paul --- .../settings/Delegate-when-out-of-office.md | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index c7a6b7eb28eb..09e66f245e4e 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -1,52 +1,72 @@ --- title: Delegate When Out of Office -description: Assign a vacation delegate to approve reports on your behalf while you're away. +description: Assign a Vacation delegate to approve reports on your behalf while you're out of office. keywords: [New Expensify, delegate approvals, out-of-office approver, vacation, out of office, approval delegation, temporary delegate, vacation delegate, copilot vs delegate, copilot, shared access, proxy, behalf of] +internalScope: Audience is New Expensify members. Covers assigning and removing your own Vacation delegate from your profile status. Does not cover Copilot access or Expensify Classic delegates. --- -If you're out of the office or on vacation, you can assign a **Vacation Delegate** to handle your approvals while you're away. +If you're out of the office or on vacation, you can assign a **Vacation delegate** to handle your approvals while you're away. -A **Vacation Delegate** can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. When you're back, just remove the delegate to resume handling your own reports. +A **Vacation delegate** can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. When you're back, just remove the delegate to resume handling your own reports. All delegate actions are tracked in the report history for full visibility. --- -# Assign a Vacation Delegate +# Delegate When Out of Office + +## How to assign a Vacation delegate 1. Go to **Account > Profile > Status**. -2. Scroll to the **Vacation Delegate** section. -3. Enter the email address or phone number of your delegate. -4. Click **Save**. +2. Scroll to the **Vacation delegate** section. +3. Select **Vacation delegate**. +4. Search by name, email, or phone number. +5. Select the person you want as your delegate. + +Your delegate is saved as soon as you select them, so there is no separate save step. + +If the person you select isn't a member of all your workspaces, a **Heads up!** message appears explaining that an email will be sent to your Workspace Admins to add them. Select **Confirm** to continue, or **Cancel** to choose someone else. Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. -## Remove Your Delegate + + +## How to remove your Vacation delegate When you're ready to take back control of your reports: 1. Go to **Account > Profile > Status**. -2. Scroll to the **Vacation Delegate** section. -3. Clear the delegate's email or phone number. -4. Click **Save**. +2. Scroll to the **Vacation delegate** section. +3. Select your current delegate. +4. Select the same person again to clear them. + +The **Vacation delegate** row goes back to being empty once the delegate is removed. --- # FAQ -## Can my admin assign a Vacation Delegate for me? +## Can my admin assign a Vacation delegate for me? + +No. In New Expensify, only you can assign your own Vacation delegate. + +## Why does my Vacation delegate show a phone number instead of a name? -No. In New Expensify, only you can assign your own Vacation Delegate. +If you added your delegate by phone number and they haven't set a display name, Expensify shows their phone number formatted for your country. This is expected, and the delegate still receives your approvals as normal. -## Why can't my Vacation Delegate reimburse reports? +## Why can't my Vacation delegate reimburse reports? Your delegate can only reimburse reports if they also have access to the reimbursement account in your workspace. Without access, they won't see the option to reimburse—even if they're your delegate. ## What if my delegate is also on vacation? -Your delegate can assign their own Vacation Delegate too. This ensures approvals keep moving, even when multiple members are away. +Your delegate can assign their own Vacation delegate too. This ensures approvals keep moving, even when multiple members are away. -## If I clear my status, does that remove my Vacation Delegate? +## If I clear my status, does that remove my Vacation delegate? No. Clearing your status won't affect your delegate. You'll need to manually remove them when you're back. @@ -56,6 +76,6 @@ If your delegate isn’t part of your workspace, Workspace Admins will be notifi ## What should I use if someone needs to manage my expenses permanently? -A Vacation Delegate is intended for temporary approval coverage while you're away. If someone needs ongoing access to manage expenses on your behalf—such as an executive assistant, accountant, or bookkeeper, [add them as a Copilot](/articles/new-expensify/settings/Manage-Copilot-Access) instead. +A Vacation delegate is intended for temporary approval coverage while you're away. If someone needs ongoing access to manage expenses on your behalf—such as an executive assistant, accountant, or bookkeeper, [add them as a Copilot](/articles/new-expensify/settings/Manage-Copilot-Access) instead. -Copilots provide long-term account access for expense management, while Vacation Delegates are designed for temporary approval coverage. +Copilots provide long-term account access for expense management, while Vacation delegates are designed for temporary approval coverage. From 18cf4fa8d0aa303e2a2551e91bcff2666840d976 Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 1 Sep 2026 18:02:39 -0700 Subject: [PATCH 094/485] fix: use nullish coalescing for chatRoomSubtitle Satisfy @typescript-eslint/prefer-nullish-coalescing after dropping useMemo. --- src/pages/DynamicReportDetailsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 4d3a2978b788..f0c07ecaf82f 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -286,7 +286,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const ancestors = useAncestors(report); const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, false, isReportArchived); - const chatRoomSubtitle = subtitle || ''; + const chatRoomSubtitle = subtitle ?? ''; const isSystemChat = isSystemChatUtil(report); const isGroupChat = isGroupChatUtil(report); From a17327255dc460373cd8d353e669ed1fa75ea98c Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 03:40:26 +0000 Subject: [PATCH 095/485] Drop the Spend exit fades and hide the hydrate placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reanimated fades an exiting element on web by detaching its DOM node and re-inserting it, so unmounting the tree mid-animation left React removing a node that had moved — NotFoundError from commitDeletionEffectsOnFiber, reproducible by cold starting and going straight to Spend. The same detaching left a chart's Skia canvas without a drawing surface. Nothing is lost visually: the hold already keeps the previous results on screen until the new ones resolve, which is what the exit fade was standing in for. The remaining skeleton was the placeholder SearchWithNavigationDeferredMount shows while it hydrates, so a swap that replaces results renders it invisibly. It stays mounted rather than skipped, which keeps its onLayout ending the navigate-to-Search spans and keeps the mount deferred so it yields to the press that triggered the swap. FADE_DURATION 120 -> 100. --- src/CONST/index.ts | 2 +- .../SearchWithNavigationDeferredMount.tsx | 23 +++++++++++-------- src/pages/Search/SearchPageNarrow/index.tsx | 15 +++++++----- src/pages/Search/SearchPageWide.tsx | 14 +++++++---- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c48e35a84e93..a3e5d48d4463 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7802,7 +7802,7 @@ const CONST = { SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', ANIMATION: { - FADE_DURATION: 120, + FADE_DURATION: 100, // How long the results area keeps showing the previous query's results while a new query loads. Past this // a slow query gives them up for the skeleton, so the wait is visible. diff --git a/src/components/Search/SearchWithNavigationDeferredMount.tsx b/src/components/Search/SearchWithNavigationDeferredMount.tsx index 76eb379e3863..c53d6fe9a26b 100644 --- a/src/components/Search/SearchWithNavigationDeferredMount.tsx +++ b/src/components/Search/SearchWithNavigationDeferredMount.tsx @@ -13,8 +13,7 @@ import CONST from '@src/CONST'; import type {ComponentProps} from 'react'; import React from 'react'; -import {StyleSheet} from 'react-native'; -import Animated, {FadeOut} from 'react-native-reanimated'; +import {StyleSheet, View} from 'react-native'; import Search from './index'; @@ -32,7 +31,12 @@ function handleSkeletonLayout() { } } -function SearchWithNavigationDeferredMount(props: ComponentProps) { +type SearchWithNavigationDeferredMountProps = ComponentProps & { + /** True when this mount replaces results already on screen, which renders the placeholder invisibly. */ + isReplacingContent?: boolean; +}; + +function SearchWithNavigationDeferredMount({isReplacingContent = false, ...props}: SearchWithNavigationDeferredMountProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const containerStyle = shouldUseNarrowLayout ? styles.searchListContentContainerStyles(!!props.hasFilterBars) : undefined; @@ -41,18 +45,17 @@ function SearchWithNavigationDeferredMount(props: ComponentProps) + // Absolutely filled so it never shares the parent's column layout with the incoming Search content. + // When it is replacing results already on screen it renders invisibly rather than being skipped: the + // skeleton would read as a flash between them, but its onLayout still ends the navigate-to-Search + // spans, and keeping the mount deferred lets it yield to the press that triggered the swap. + - + } > diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 771e501f05ba..8f865cbe50b8 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -19,6 +19,7 @@ import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; import {useLoadingBarVisibility} from '@hooks/useInFlightRequests'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePrevious from '@hooks/usePrevious'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSearchLoadingState from '@hooks/useSearchLoadingState'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -43,7 +44,7 @@ import type {SearchResults} from '@src/types/onyx'; import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useRef, useState, useTransition} from 'react'; import {StyleSheet, View} from 'react-native'; -import Animated, {clamp, FadeIn, FadeOut, LayoutAnimationConfig, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {clamp, FadeIn, LayoutAnimationConfig, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import {SearchActionsBarSwitch, SearchFiltersBarSwitch, SearchPageInputSwitch, SearchTypeMenuSwitch} from './Switches'; @@ -56,10 +57,8 @@ type SearchPageNarrowProps = { queryJSON?: SearchQueryJSON; searchResults?: SearchResults; - /** The last query whose results resolved. Drives the results area so it holds the current results while a new query loads. */ + /** The last query whose results resolved. The area renders these, holding them while a new query loads. */ contentQueryJSON?: SearchQueryJSON; - - /** Results for `contentQueryJSON`. */ contentSearchResults?: SearchResults; isMobileSelectionModeEnabled: boolean; @@ -89,6 +88,11 @@ function SearchPageNarrow({ isOverlayActive, }: SearchPageNarrowProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(contentQueryJSON, contentSearchResults); + + // A layer replacing results already on screen renders its hydrate placeholder invisibly, since a skeleton there + // reads as a flash between two sets of results. + const previousContentHash = usePrevious(contentQueryJSON?.hash); + const isReplacingPreviousContent = previousContentHash !== contentQueryJSON?.hash; const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); @@ -217,7 +221,6 @@ function SearchPageNarrow({ }, [isHeaderInteractive, isInteractive, startTransition]), ); - // contentQueryJSON falls back to queryJSON upstream, so the two are always absent together. if (!queryJSON || !contentQueryJSON) { return ( {shouldShowLoadingSkeleton ? ( ) : ( ; - /** The last query whose results resolved. Drives the results area so it holds the current results while a new query loads. */ + /** The last query whose results resolved. The area renders these, holding them while a new query loads. */ contentQueryJSON?: SearchQueryJSON; - - /** Results for `contentQueryJSON`. */ contentSearchResults: OnyxEntry; isMobileSelectionModeEnabled: boolean; @@ -67,6 +66,11 @@ function SearchPageWide({ onSearchContentReady, }: SearchPageWideProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(contentQueryJSON, contentSearchResults); + + // A layer replacing results already on screen renders its hydrate placeholder invisibly, since a skeleton there + // reads as a flash between two sets of results. + const previousContentHash = usePrevious(contentQueryJSON?.hash); + const isReplacingPreviousContent = previousContentHash !== contentQueryJSON?.hash; const styles = useThemeStyles(); const {currentSearchKey} = useSearchQueryContext(); const {hasSelectedTransactions} = useSearchSelectionContext(); @@ -139,13 +143,13 @@ function SearchPageWide({ {shouldShowLoadingSkeleton ? ( ) : ( Date: Wed, 2 Sep 2026 17:02:06 +0530 Subject: [PATCH 096/485] Look up last accessed report on leave and delete tap --- src/pages/DynamicReportDetailsPage.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 97103be7a0de..c996e0da1eab 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -26,7 +26,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDeleteTransactions from '@hooks/useDeleteTransactions'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import useFindLastAccessedReport from '@hooks/useFindLastAccessedReport'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import useHasOutstandingChildTask from '@hooks/useHasOutstandingChildTask'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; @@ -66,6 +65,7 @@ import { canLeaveChat, canWriteInReport, createDraftTransactionAndNavigateToParticipantSelector, + findLastAccessedReport, getAvailableReportFields, getChatRoomSubtitle, getIcons, @@ -216,11 +216,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const hasOutstandingChildTask = useHasOutstandingChildTask(report); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); - const {lastAccessedReport} = useFindLastAccessedReport({ - excludeReportID: report.reportID, - openOnAdminRoom: false, - }); - const lastAccessedReportID = lastAccessedReport?.reportID; + // Last-accessed lookup runs on leave/delete, not on every report or NVP write. + const [lastAccessedReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [pendingDeleteMemberAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingDeleteMemberAccountIDsSelector}); @@ -398,6 +396,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); const leaveChat = useCallback(() => { + const lastAccessedReportID = findLastAccessedReport(false, false, report.reportID, lastAccessedReportNameValuePairs, reports)?.reportID; if (isRootGroupChat) { leaveGroupChat( report, @@ -425,7 +424,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report introSelected, isSelfTourViewed, betas, - lastAccessedReportID, + lastAccessedReportNameValuePairs, + reports, ]); const showLastMemberLeavingModal = useCallback(async () => { @@ -1037,7 +1037,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report { ancestors, shouldNavigateBack: !taskDeleteBackTo, - lastAccessedReportID, + lastAccessedReportID: findLastAccessedReport(false, false, report.reportID, lastAccessedReportNameValuePairs, reports)?.reportID, }, ); return; @@ -1094,7 +1094,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report conciergeReportID, delegateEmail, ancestors, - lastAccessedReportID, + lastAccessedReportNameValuePairs, + reports, reportActionsForOriginalReportID, moneyRequestReport, moneyRequestReportActions, From 5d2f9c340c6e3cdc3b9b2160035cd9a0ce002cdf Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:12:48 +0530 Subject: [PATCH 097/485] Drop empty-string last accessed report IDs and rebuild GH action bundle --- .../isDeployChecklistLocked/index.js | 255 +++++++++++++++++- src/libs/actions/Report/index.ts | 8 +- src/libs/actions/Task.ts | 6 +- src/pages/DynamicReportDetailsPage.tsx | 4 +- tests/actions/TaskTest.ts | 4 +- 5 files changed, 261 insertions(+), 16 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index f0d47fc82f50..d228861485f1 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -3431,7 +3431,9 @@ var require_CONST = __commonJS({ REGISTER_AUTHENTICATION_KEY: "register_authentication_key", REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", - REPORT_CARD_FRAUD: "report_card_fraud" + REPORT_CARD_FRAUD: "report_card_fraud", + ISSUE_CARD: "issue_card", + UPDATE_CARD: "update_card" }, EXPENSIFY_CARD: { FEED_NAME: "Expensify Card", @@ -23108,9 +23110,22 @@ var require_ExpensiMark = __commonJS({ var Logger_1 = __importDefault(require_Logger()); var Utils = __importStar(require_utils()); var EXTRAS_DEFAULT = {}; + var ASCII_DIGIT_START = "0".charCodeAt(0); + var ASCII_DIGIT_END = "9".charCodeAt(0); + var ASCII_UPPERCASE_START = "A".charCodeAt(0); + var ASCII_UPPERCASE_END = "Z".charCodeAt(0); + var ASCII_LOWERCASE_START = "a".charCodeAt(0); + var ASCII_LOWERCASE_END = "z".charCodeAt(0); + var ASCII_WHITESPACE_END = " ".charCodeAt(0); + var NON_BREAKING_SPACE_CODE = 160; + var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; + var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join("|")}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); + var BOLD_MARKDOWN_REGEX = /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; + var STRIKETHROUGH_MARKDOWN_REGEX = /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; var SLACK_SPAN_NEW_LINE_TAG = ''; var VICTORY_CHART_REGEX = /]*\/>|]*>[\s\S]*?<\/VictoryChart>/gi; var VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0); @@ -23150,6 +23165,218 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function isAsciiAlphaNumeric(character) { + if (!character) { + return false; + } + const code = character.charCodeAt(0); + return code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END || code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END || code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END; + } + function isWordCharacter(character) { + return character === "_" || isAsciiAlphaNumeric(character); + } + function canOpenBoldMarkdown(text, position, isProtected) { + if (isProtected) { + return false; + } + const nextCharacter = text[position + 1]; + if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith("", tagStart + 1); + if (tagEnd === -1) { + return void 0; + } + const tag = text.slice(tagStart + 1, tagEnd).trim(); + const isClosingTag = tag.startsWith("/"); + const tagName = (_b = (_a = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + if (tagName && PROTECTED_TAG_NAMES.has(tagName)) { + if (isClosingTag) { + const matchingTagIndex = protectedTags.lastIndexOf(tagName); + if (matchingTagIndex !== -1) { + protectedTags.splice(matchingTagIndex, 1); + } + } else if (!tag.endsWith("/")) { + protectedTags.push(tagName); + } + } + return tagEnd + 1; + } + function isHostnameCharacter(character) { + return !!character && (isAsciiAlphaNumeric(character) || character === "-" || character === "."); + } + function isUrlBoundarySpace(character) { + const code = character.charCodeAt(0); + return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE; + } + function getProtocolAt(text, position) { + var _a; + const firstCharacter = (_a = text[position]) === null || _a === void 0 ? void 0 : _a.toLowerCase(); + if (firstCharacter !== "h" && firstCharacter !== "f") { + return void 0; + } + return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); + } + function findHostnameEnd(text, hostnameStart, dotPosition) { + let hostnameEnd = dotPosition + 1; + while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { + hostnameEnd++; + } + if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { + return void 0; + } + return hostnameEnd; + } + function extendUrlCandidateBoundaries(text, start, end) { + let candidateStart = start; + while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) { + candidateStart--; + } + let candidateEnd = end; + while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== "<") { + candidateEnd++; + } + return { start: candidateStart, end: candidateEnd }; + } + function findUrlCandidates(text) { + const candidates = []; + const protectedTags = []; + let index = 0; + let hostnameRunStart = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + hostnameRunStart = index; + continue; + } + if (protectedTags.length > 0) { + index++; + hostnameRunStart = index; + continue; + } + const matchedProtocol = getProtocolAt(text, index); + if (matchedProtocol) { + const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + candidates.push(candidate2); + index = candidate2.end; + hostnameRunStart = candidate2.end; + continue; + } + if (!isHostnameCharacter(text[index])) { + hostnameRunStart = index + 1; + index++; + continue; + } + if (text[index] !== ".") { + index++; + continue; + } + const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); + if (hostnameEnd === void 0) { + index++; + continue; + } + const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + candidates.push(candidate); + index = candidate.end; + hostnameRunStart = candidate.end; + } + return candidates; + } + function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { + if (!text.includes(marker)) { + return text; + } + const markers = []; + const protectedTags = []; + let index = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + continue; + } + if (text[index] === marker) { + markers.push({ position: index, isProtected: protectedTags.length > 0 }); + } + index++; + } + if (markers.length < 2) { + return text; + } + const output = []; + const candidateRegex = regexp; + let outputStart = 0; + let openingMarker; + for (const currentMarker of markers) { + const markerPosition = currentMarker.position; + if (openingMarker === void 0) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) { + continue; + } + if (openingMarker.isProtected) { + openingMarker = void 0; + continue; + } + const openingPosition = openingMarker.position; + const prefixLength = openingPosition > 0 ? 1 : 0; + const suffixLength = markerPosition + 1 < text.length ? 1 : 0; + const candidateStart = openingPosition - prefixLength; + const candidateEnd = markerPosition + 1 + suffixLength; + const candidate = text.slice(candidateStart, candidateEnd); + candidateRegex.lastIndex = 0; + const candidateMatch = candidateRegex.exec(candidate); + if (!candidateMatch) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + candidateRegex.lastIndex = 0; + const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement); + if (replacedCandidate !== candidate) { + const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length; + output.push(text.slice(outputStart, openingPosition)); + output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd)); + outputStart = markerPosition + 1; + openingMarker = void 0; + continue; + } + openingMarker = void 0; + } + if (output.length === 0) { + return text; + } + output.push(text.slice(outputStart)); + return output.join(""); + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
> (|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -23570,7 +23797,7 @@ var require_ExpensiMark = __commonJS({ name: "autolink", process: (textToProcess, replacement) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -23675,7 +23902,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - regex: /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), replacement: (_extras, match, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -23685,7 +23912,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match, g1) => g1.includes("") || containsNonPairTag(g1) ? match : `${g1}` }, { @@ -24075,7 +24302,25 @@ ${g2} /** * Checks matched URLs for validity and replace valid links with html elements */ - modifyTextForUrlLinks(regex2, textToCheck, replacement) { + modifyTextForUrlLinks(regex2, textToCheck, replacement, shouldScanForUrls = false) { + if (shouldScanForUrls) { + const candidates = findUrlCandidates(textToCheck); + if (candidates.length === 0) { + return textToCheck; + } + const output = []; + const candidateRegex = regex2; + let outputStart = 0; + for (const { start, end } of candidates) { + const candidate = textToCheck.slice(start, end); + candidateRegex.lastIndex = 0; + output.push(textToCheck.slice(outputStart, start)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + outputStart = end; + } + output.push(textToCheck.slice(outputStart)); + return output.join(""); + } let match = regex2.exec(textToCheck); let replacedText = ""; let startIndex = 0; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 254df2306788..aac09bc5d4cd 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5096,7 +5096,7 @@ function navigateToMostRecentReport( introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, betas: OnyxEntry, - lastAccessedReportID: string, + lastAccessedReportID?: string, ) { if (lastAccessedReportID) { // Check if route exists for super wide RHP vs regular full screen report @@ -5135,7 +5135,7 @@ function getSearchThreadLeaveRoute(report: Report, activeRoute: string): Route | }); } -function getMostRecentReportID(conciergeReportID: string | undefined, lastAccessedReportID: string) { +function getMostRecentReportID(conciergeReportID: string | undefined, lastAccessedReportID?: string) { return lastAccessedReportID || conciergeReportID; } @@ -5161,7 +5161,7 @@ function leaveGroupChat( introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, betas: OnyxEntry, - lastAccessedReportID = '', + lastAccessedReportID?: string, ) { const reportID = report.reportID; // Use merge instead of set to avoid deleting the report too quickly, which could cause a brief "not found" page to appear. @@ -5226,7 +5226,7 @@ function leaveRoom( isSelfTourViewed: boolean | undefined, betas: OnyxEntry, isWorkspaceMemberLeavingWorkspaceRoom = false, - lastAccessedReportID = '', + lastAccessedReportID?: string, ) { const reportID = report.reportID; const isChatThread = isChatThreadReportUtils(report); diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 3bef0d734f13..459679bbbbb3 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -113,7 +113,7 @@ type DeleteTaskOptions = { ancestors?: ReportUtils.Ancestor[]; shouldNavigateBack?: boolean; /** Fallback report ID when the deleted task has no parent report. */ - lastAccessedReportID: string; + lastAccessedReportID?: string; }; /** @@ -1336,7 +1336,7 @@ function getNavigationUrlOnTaskDelete( report: OnyxEntry, conciergeReportID: string | undefined, reportActions: OnyxEntry, - lastAccessedReportID = '', + lastAccessedReportID?: string, ): string | undefined { if (!report) { return undefined; @@ -1373,7 +1373,7 @@ function deleteTask( conciergeReportID: string | undefined, delegateEmail: string | undefined, reportActions: OnyxEntry, - {ancestors = [], shouldNavigateBack = true, lastAccessedReportID}: DeleteTaskOptions = {lastAccessedReportID: ''}, + {ancestors = [], shouldNavigateBack = true, lastAccessedReportID}: DeleteTaskOptions = {}, ) { if (!report) { return; diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 764afe139060..af0dc63f938b 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -397,7 +397,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); const leaveChat = useCallback(() => { - const lastAccessedReportID = findLastAccessedReport(false, guideAccountIDs, false, report.reportID, lastAccessedReportNameValuePairs, reports)?.reportID ?? ''; + const lastAccessedReportID = findLastAccessedReport(false, guideAccountIDs, false, report.reportID, lastAccessedReportNameValuePairs, reports)?.reportID; if (isRootGroupChat) { leaveGroupChat( report, @@ -1039,7 +1039,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report { ancestors, shouldNavigateBack: !taskDeleteBackTo, - lastAccessedReportID: findLastAccessedReport(false, guideAccountIDs, false, report.reportID, lastAccessedReportNameValuePairs, reports)?.reportID ?? '', + lastAccessedReportID: findLastAccessedReport(false, guideAccountIDs, false, report.reportID, lastAccessedReportNameValuePairs, reports)?.reportID, }, ); return; diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index 62dd6197e9de..8d37403ed00f 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -1460,7 +1460,7 @@ describe('actions/Task', () => { const result = getNavigationUrlOnTaskDelete(taskReport, conciergeReportID, undefined); expect(result).toBe(`r/${conciergeReportID}`); - expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(conciergeReportID, ''); + expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(conciergeReportID, undefined); }); it('should return undefined when no parentReportID, no most recent report, and conciergeReportID is undefined', () => { @@ -1820,7 +1820,7 @@ describe('actions/Task', () => { const result = deleteTask(taskReport, undefined, false, mockCurrentUserAccountID, false, undefined, conciergeReportID, undefined, undefined); expect(result).toBe(`r/${conciergeReportID}`); - expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(conciergeReportID, ''); + expect(getMostRecentReportIDSpy).toHaveBeenCalledWith(conciergeReportID, undefined); expect(Navigation.goBack).toHaveBeenCalled(); }); From 305d54d1bc7660bea7669e6e2a941fc4799f351b Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 16:25:49 +0000 Subject: [PATCH 098/485] Keep the Spend hydrate placeholder hidden for its whole mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isReplacingContent is derived from usePrevious, whose ref updates in an effect, so it was true only on the render where the query hash changed and false on the next one — while NavigationDeferredMount could still be showing its placeholder. Any re-render in that window revealed the skeleton, which is the intermittent flash between two sets of results. Capturing the prop at mount keeps it correct for the whole hydrate. --- .../Search/SearchWithNavigationDeferredMount.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchWithNavigationDeferredMount.tsx b/src/components/Search/SearchWithNavigationDeferredMount.tsx index c53d6fe9a26b..9c5656a1b48c 100644 --- a/src/components/Search/SearchWithNavigationDeferredMount.tsx +++ b/src/components/Search/SearchWithNavigationDeferredMount.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import type {ComponentProps} from 'react'; -import React from 'react'; +import React, {useState} from 'react'; import {StyleSheet, View} from 'react-native'; import Search from './index'; @@ -37,6 +37,9 @@ type SearchWithNavigationDeferredMountProps = ComponentProps & { }; function SearchWithNavigationDeferredMount({isReplacingContent = false, ...props}: SearchWithNavigationDeferredMountProps) { + // Captured once: the prop is derived from usePrevious, so it flips back on the render after the query changes, + // while this placeholder can still be showing. Reading it live makes the skeleton appear partway through hydrate. + const [isReplacingContentAtMount] = useState(isReplacingContent); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const containerStyle = shouldUseNarrowLayout ? styles.searchListContentContainerStyles(!!props.hasFilterBars) : undefined; @@ -49,7 +52,7 @@ function SearchWithNavigationDeferredMount({isReplacingContent = false, ...props // When it is replacing results already on screen it renders invisibly rather than being skipped: the // skeleton would read as a flash between them, but its onLayout still ends the navigate-to-Search // spans, and keeping the mount deferred lets it yield to the press that triggered the swap. - + Date: Wed, 2 Sep 2026 17:01:25 +0000 Subject: [PATCH 099/485] Drop the delay before the Spend results fade in The delay existed to start the fade-in once the outgoing layer's fade-out finished. Nothing fades out any more, so it only left the results area showing the app background for the duration before the new results appeared. --- src/pages/Search/SearchPageNarrow/index.tsx | 2 +- src/pages/Search/SearchPageWide.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 8f865cbe50b8..5867b983cd07 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -354,7 +354,7 @@ function SearchPageNarrow({ the incoming layer instead of sharing the column layout. */} {shouldShowLoadingSkeleton ? ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 5f2673778de5..0c7e65c0ff41 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -142,7 +142,7 @@ function SearchPageWide({ the incoming layer instead of sharing the column layout. */} {shouldShowLoadingSkeleton ? ( From d66e2431de450dec83b6748a3415a4a462efb63c Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 17:07:15 +0000 Subject: [PATCH 100/485] Correct the Spend comments that described a removed fade --- src/components/Search/SearchLoadingSkeleton.tsx | 4 ++-- src/pages/Search/SearchPageNarrow/index.tsx | 6 +++--- src/pages/Search/SearchPageWide.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index 74f34614373e..e9a37127bee9 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -21,8 +21,8 @@ function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { const styles = useThemeStyles(); return ( - // The skeleton is absolutely filled so that its exit fade overlays the incoming results instead of - // sharing the parent's column layout with them, which would halve both heights for the fade duration. + // Absolutely filled so it never shares the parent's column layout with the incoming results, which would + // halve both heights for a frame. { diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 5867b983cd07..5d323d1d60cc 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -349,9 +349,9 @@ function SearchPageNarrow({ <> {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A resolved query change remounts this layer: the outgoing one fades out and the incoming one waits - for it to finish before fading in. Both layers are absolutely filled so the outgoing fade overlays - the incoming layer instead of sharing the column layout. */} + {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones + stay on screen until then, and the new ones fade in. Absolutely filled so it never shares the + parent's column layout with the layer it replaces. */} {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* A resolved query change remounts this layer: the outgoing one fades out and the incoming one waits - for it to finish before fading in. Both layers are absolutely filled so the outgoing fade overlays - the incoming layer instead of sharing the column layout. */} + {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones + stay on screen until then, and the new ones fade in. Absolutely filled so it never shares the + parent's column layout with the layer it replaces. */} Date: Wed, 2 Sep 2026 17:32:21 +0000 Subject: [PATCH 101/485] Render the Spend loading skeleton as a plain View --- src/components/Search/SearchLoadingSkeleton.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index e9a37127bee9..4879fb69f7f8 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -1,7 +1,5 @@ import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; -import useThemeStyles from '@hooks/useThemeStyles'; - import {endSpanWithAttributes} from '@libs/telemetry/activeSpans'; import {endNavigateToReportsFirstPaint} from '@libs/telemetry/navigateToReportsSpans'; @@ -10,21 +8,18 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; -import {StyleSheet} from 'react-native'; -import Animated from 'react-native-reanimated'; +import {StyleSheet, View} from 'react-native'; type SearchLoadingSkeletonProps = { containerStyle?: StyleProp; }; function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { - const styles = useThemeStyles(); - return ( // Absolutely filled so it never shares the parent's column layout with the incoming results, which would // halve both heights for a frame. - { endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.COLD); @@ -34,7 +29,7 @@ function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { shouldAnimate containerStyle={containerStyle} /> - + ); } From 9580b93097dba05a93252808eeaa0cc669c73379 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 17:37:52 +0000 Subject: [PATCH 102/485] Match the Spend footer to the results on screen SearchSelectionFooter read the live results while the list rendered the held ones, so during a hold it described the incoming query rather than the rows the user could see. --- src/pages/Search/SearchPageNarrow/index.tsx | 2 +- src/pages/Search/SearchPageWide.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 5d323d1d60cc..5af749abfa3d 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -387,7 +387,7 @@ function SearchPageNarrow({ )} - + {(!useStaticRendering || isHeaderInteractive) && ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index cf7f00edb76e..8530b36df72d 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -164,7 +164,7 @@ function SearchPageWide({ {!!searchOverlayContent && {searchOverlayContent}} - + )} From 41c80a612592cb079796fae9fafb06fb8117ece8 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 17:39:52 +0000 Subject: [PATCH 103/485] Require contentSearchResults in SearchPageNarrow --- src/pages/Search/SearchPageNarrow/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 5af749abfa3d..24087a7b0d2b 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -59,7 +59,7 @@ type SearchPageNarrowProps = { /** The last query whose results resolved. The area renders these, holding them while a new query loads. */ contentQueryJSON?: SearchQueryJSON; - contentSearchResults?: SearchResults; + contentSearchResults: SearchResults | undefined; isMobileSelectionModeEnabled: boolean; onSortPressedCallback: () => void; From b7697497fd938092b783f2d9166d5b75e4078c33 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 17:50:41 +0000 Subject: [PATCH 104/485] Clear the Spend sorting flag when the search changes Sorting keeps the previous results on screen while the re-sorted ones load. Opening a search no longer remounts the page, so the flag survived, and a sidebar switch mid-sort rendered the previous query's rows under the new query. --- src/pages/Search/SearchPage.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f535849c158b..f0953bfb3e63 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -76,6 +76,14 @@ function SearchPage({route}: SearchPageProps) { const [isSorting, setIsSorting] = useState(false); + // Sorting keeps the previous results on screen while the re-sorted ones load. Changing the search cancels that, + // and opening one no longer remounts this page, so a flag left set would render the previous query's rows under + // the new query. Adjusted during rendering because searchResults below consumes it in this same render. + const previousQueryHash = usePrevious(currentSearchQueryJSON?.hash); + if (isSorting && previousQueryHash !== currentSearchQueryJSON?.hash) { + setIsSorting(false); + } + const isCurrentSearchResolved = isSearchDataLoaded(currentSearchResults, currentSearchQueryJSON); let searchResults: SearchResults | undefined; if (isCurrentSearchResolved && currentSearchResults?.search && currentSearchResults.data === undefined) { From a8980b28d120edfedd3e5dc3d00fee4ec7df0b08 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 17:57:55 +0000 Subject: [PATCH 105/485] Let a timed-out Spend query hold again once it resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hash stayed marked for the life of the page, so a query that once exceeded the stale hold went straight to the skeleton every time after — toggling a filter off and back on, or returning after a snapshot was evicted. --- src/pages/Search/SearchPage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f0953bfb3e63..2545b9e409ed 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -150,6 +150,13 @@ function SearchPage({route}: SearchPageProps) { return () => clearTimeout(timeoutID); }, [isSearchResolvedForCurrentQuery, currentQueryHash]); + // Cleared once the query resolves, so returning to it later starts a fresh count. Leaving the hash marked would + // skip the hold for good — a filter toggled off and back on, or a snapshot evicted, would go straight to the + // skeleton. Adjusted during rendering rather than in the effect above, which must not call setState synchronously. + if (isSearchResolvedForCurrentQuery && staleHoldTimedOutHash === currentQueryHash) { + setStaleHoldTimedOutHash(undefined); + } + const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; // Only a filter refinement is worth holding for. A sidebar item or saved search is a different search, so its From 890a481f3f6d71f42f18432cd6dba17d5163a5dc Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 18:02:18 +0000 Subject: [PATCH 106/485] Clear the Spend refinement mark when a search is opened The mark survived until the filters built another query, so the same query string arriving later from the sidebar or a saved search was treated as a refinement and held the results of the search the user was leaving. --- src/libs/SearchNavigationUtils.ts | 5 +++++ src/libs/SearchQueryRefinement.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index 5cf2b2a2e466..fffebcb10ec4 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -5,6 +5,7 @@ import SCREENS from '@src/SCREENS'; import {setSearchContext} from './actions/Search'; import Navigation from './Navigation/Navigation'; import navigationRef from './Navigation/navigationRef'; +import {clearQueryRefinement} from './SearchQueryRefinement'; /** * Opens a Spend search, changing the params of the Search screen already on screen where possible. @@ -17,6 +18,10 @@ import navigationRef from './Navigation/navigationRef'; * whole navigator, so it is also visible over a report, where the params belong to a different route. */ function navigateToSpendSearch(query: string, name?: string) { + // Opening a search is never a refinement. Clearing the mark stops a query the filters built earlier from being + // held for later, when the same query string arrives from the sidebar or a saved search instead. + clearQueryRefinement(); + if (navigationRef.isReady() && navigationRef.getCurrentRoute()?.name === SCREENS.SEARCH.ROOT) { navigationRef.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS, diff --git a/src/libs/SearchQueryRefinement.ts b/src/libs/SearchQueryRefinement.ts index 2f7d496f5ba7..d8a8e16fd34e 100644 --- a/src/libs/SearchQueryRefinement.ts +++ b/src/libs/SearchQueryRefinement.ts @@ -8,8 +8,8 @@ import type {SearchQueryString} from '@components/Search/types'; * different search, where holding the old results would show rows unrelated to the destination. A query alone can't * tell the two apart — both arrive as a new `q` on the same screen — so the filter path records its query here. * - * Only the most recent query is kept: an unknown query is treated as a different search, so a missed mark costs a - * skeleton rather than showing stale results. + * Only the most recent query is kept, and opening a search clears it: an unknown query is treated as a different + * search, so a missed mark costs a skeleton rather than showing stale results. */ let refinedQuery: SearchQueryString | undefined; @@ -17,8 +17,12 @@ function markQueryAsRefinement(query: SearchQueryString) { refinedQuery = query; } +function clearQueryRefinement() { + refinedQuery = undefined; +} + function isQueryARefinement(query: SearchQueryString | undefined) { return !!query && refinedQuery === query; } -export {markQueryAsRefinement, isQueryARefinement}; +export {markQueryAsRefinement, clearQueryRefinement, isQueryARefinement}; From 7f3d85837d7615a06c1f6b3b7011689347e4d849 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:43:00 +0530 Subject: [PATCH 107/485] Use nullish coalescing for last accessed report fallback --- src/libs/actions/Report/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 70f60867ff12..6a96e71ffed6 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5138,7 +5138,7 @@ function getSearchThreadLeaveRoute(report: Report, activeRoute: string): Route | } function getMostRecentReportID(conciergeReportID: string | undefined, lastAccessedReportID?: string) { - return lastAccessedReportID || conciergeReportID; + return lastAccessedReportID ?? conciergeReportID; } function joinRoom(report: OnyxEntry, currentUserAccountID: number) { From 53f983200b9ad41332f8b463ea19afd60238f985 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:10:11 +0530 Subject: [PATCH 108/485] Restore proposalPoliceComment GH action bundle from main --- .../javascript/proposalPoliceComment/index.js | 255 +++++++++++++++++- 1 file changed, 250 insertions(+), 5 deletions(-) diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 3fa5c1e69771..8310177805a9 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -24245,7 +24245,9 @@ var require_CONST = __commonJS({ REGISTER_AUTHENTICATION_KEY: "register_authentication_key", REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", - REPORT_CARD_FRAUD: "report_card_fraud" + REPORT_CARD_FRAUD: "report_card_fraud", + ISSUE_CARD: "issue_card", + UPDATE_CARD: "update_card" }, EXPENSIFY_CARD: { FEED_NAME: "Expensify Card", @@ -43922,9 +43924,22 @@ var require_ExpensiMark = __commonJS({ var Logger_1 = __importDefault(require_Logger()); var Utils = __importStar(require_utils2()); var EXTRAS_DEFAULT = {}; + var ASCII_DIGIT_START = "0".charCodeAt(0); + var ASCII_DIGIT_END = "9".charCodeAt(0); + var ASCII_UPPERCASE_START = "A".charCodeAt(0); + var ASCII_UPPERCASE_END = "Z".charCodeAt(0); + var ASCII_LOWERCASE_START = "a".charCodeAt(0); + var ASCII_LOWERCASE_END = "z".charCodeAt(0); + var ASCII_WHITESPACE_END = " ".charCodeAt(0); + var NON_BREAKING_SPACE_CODE = 160; + var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; + var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join("|")}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); + var BOLD_MARKDOWN_REGEX = /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; + var STRIKETHROUGH_MARKDOWN_REGEX = /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; var SLACK_SPAN_NEW_LINE_TAG = ''; var VICTORY_CHART_REGEX = /]*\/>|]*>[\s\S]*?<\/VictoryChart>/gi; var VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0); @@ -43964,6 +43979,218 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function isAsciiAlphaNumeric(character) { + if (!character) { + return false; + } + const code = character.charCodeAt(0); + return code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END || code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END || code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END; + } + function isWordCharacter(character) { + return character === "_" || isAsciiAlphaNumeric(character); + } + function canOpenBoldMarkdown(text, position, isProtected) { + if (isProtected) { + return false; + } + const nextCharacter = text[position + 1]; + if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith("", tagStart + 1); + if (tagEnd === -1) { + return void 0; + } + const tag = text.slice(tagStart + 1, tagEnd).trim(); + const isClosingTag = tag.startsWith("/"); + const tagName = (_b = (_a3 = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a3 === void 0 ? void 0 : _a3[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + if (tagName && PROTECTED_TAG_NAMES.has(tagName)) { + if (isClosingTag) { + const matchingTagIndex = protectedTags.lastIndexOf(tagName); + if (matchingTagIndex !== -1) { + protectedTags.splice(matchingTagIndex, 1); + } + } else if (!tag.endsWith("/")) { + protectedTags.push(tagName); + } + } + return tagEnd + 1; + } + function isHostnameCharacter(character) { + return !!character && (isAsciiAlphaNumeric(character) || character === "-" || character === "."); + } + function isUrlBoundarySpace(character) { + const code = character.charCodeAt(0); + return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE; + } + function getProtocolAt(text, position) { + var _a3; + const firstCharacter = (_a3 = text[position]) === null || _a3 === void 0 ? void 0 : _a3.toLowerCase(); + if (firstCharacter !== "h" && firstCharacter !== "f") { + return void 0; + } + return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); + } + function findHostnameEnd(text, hostnameStart, dotPosition) { + let hostnameEnd = dotPosition + 1; + while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { + hostnameEnd++; + } + if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { + return void 0; + } + return hostnameEnd; + } + function extendUrlCandidateBoundaries(text, start, end) { + let candidateStart = start; + while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) { + candidateStart--; + } + let candidateEnd = end; + while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== "<") { + candidateEnd++; + } + return { start: candidateStart, end: candidateEnd }; + } + function findUrlCandidates(text) { + const candidates = []; + const protectedTags = []; + let index = 0; + let hostnameRunStart = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + hostnameRunStart = index; + continue; + } + if (protectedTags.length > 0) { + index++; + hostnameRunStart = index; + continue; + } + const matchedProtocol = getProtocolAt(text, index); + if (matchedProtocol) { + const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + candidates.push(candidate2); + index = candidate2.end; + hostnameRunStart = candidate2.end; + continue; + } + if (!isHostnameCharacter(text[index])) { + hostnameRunStart = index + 1; + index++; + continue; + } + if (text[index] !== ".") { + index++; + continue; + } + const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); + if (hostnameEnd === void 0) { + index++; + continue; + } + const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + candidates.push(candidate); + index = candidate.end; + hostnameRunStart = candidate.end; + } + return candidates; + } + function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { + if (!text.includes(marker)) { + return text; + } + const markers = []; + const protectedTags = []; + let index = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + continue; + } + if (text[index] === marker) { + markers.push({ position: index, isProtected: protectedTags.length > 0 }); + } + index++; + } + if (markers.length < 2) { + return text; + } + const output = []; + const candidateRegex = regexp; + let outputStart = 0; + let openingMarker; + for (const currentMarker of markers) { + const markerPosition = currentMarker.position; + if (openingMarker === void 0) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) { + continue; + } + if (openingMarker.isProtected) { + openingMarker = void 0; + continue; + } + const openingPosition = openingMarker.position; + const prefixLength = openingPosition > 0 ? 1 : 0; + const suffixLength = markerPosition + 1 < text.length ? 1 : 0; + const candidateStart = openingPosition - prefixLength; + const candidateEnd = markerPosition + 1 + suffixLength; + const candidate = text.slice(candidateStart, candidateEnd); + candidateRegex.lastIndex = 0; + const candidateMatch = candidateRegex.exec(candidate); + if (!candidateMatch) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + candidateRegex.lastIndex = 0; + const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement); + if (replacedCandidate !== candidate) { + const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length; + output.push(text.slice(outputStart, openingPosition)); + output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd)); + outputStart = markerPosition + 1; + openingMarker = void 0; + continue; + } + openingMarker = void 0; + } + if (output.length === 0) { + return text; + } + output.push(text.slice(outputStart)); + return output.join(""); + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -44384,7 +44611,7 @@ var require_ExpensiMark = __commonJS({ name: "autolink", process: (textToProcess, replacement) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -44489,7 +44716,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - regex: /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), replacement: (_extras, match2, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -44499,7 +44726,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match2, g1) => g1.includes("") || containsNonPairTag(g1) ? match2 : `${g1}` }, { @@ -44889,7 +45116,25 @@ ${g2} /** * Checks matched URLs for validity and replace valid links with html elements */ - modifyTextForUrlLinks(regex2, textToCheck, replacement) { + modifyTextForUrlLinks(regex2, textToCheck, replacement, shouldScanForUrls = false) { + if (shouldScanForUrls) { + const candidates = findUrlCandidates(textToCheck); + if (candidates.length === 0) { + return textToCheck; + } + const output = []; + const candidateRegex = regex2; + let outputStart = 0; + for (const { start, end } of candidates) { + const candidate = textToCheck.slice(start, end); + candidateRegex.lastIndex = 0; + output.push(textToCheck.slice(outputStart, start)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + outputStart = end; + } + output.push(textToCheck.slice(outputStart)); + return output.join(""); + } let match2 = regex2.exec(textToCheck); let replacedText = ""; let startIndex = 0; From faa5613e71b4504d74d88afce934f64095f2ac50 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 21:12:50 +0000 Subject: [PATCH 109/485] Update fade duration --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a3e5d48d4463..ce50557fb371 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7802,7 +7802,7 @@ const CONST = { SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', ANIMATION: { - FADE_DURATION: 100, + FADE_DURATION: 150, // How long the results area keeps showing the previous query's results while a new query loads. Past this // a slow query gives them up for the skeleton, so the wait is visible. From 743c63a49f7cf7080a263d82b60fd2ed4cfda584 Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Wed, 2 Sep 2026 23:07:00 +0000 Subject: [PATCH 110/485] Docs: add the Export step to the grouped export instructions Co-authored-by: Yauheni Horbach --- .../reports-and-expenses/Search-and-Download-Expenses.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md index 81d9f1ebc9c1..c043169b5048 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md @@ -65,7 +65,8 @@ When you group expenses on the **Spend** page, you can export the grouped result 3. Click **Apply**. 4. Check the box next to the groups you want to export, or use the top checkbox to select all. 5. Click the selection button at the top (for example, **1 selected**). -6. Select **Current view**. +6. Select **Export**. If **Export** is the only action available, the export options are already listed under an **Export** heading—skip to the next step. +7. Select **Current view**. In the exported file: - Each group appears as its own section with a header row. @@ -104,7 +105,7 @@ Yes, use the **All Data – Report Level Export** template. All other templates ## Can I keep my groups when I export? -Yes. Group your expenses using **Display** > **Group by**, select the groups, then choose **Current view**. The exported file keeps each group as its own section with the expenses listed below their group header, using the columns currently shown on the page. +Yes. Group your expenses using **Display** > **Group by**, select the groups, then choose **Export** > **Current view**. The exported file keeps each group as its own section with the expenses listed below their group header, using the columns currently shown on the page. ## Can I export in PDF or XLS format? @@ -117,7 +118,7 @@ No, PDF export is only available for full reports. ## Can I customize the columns in the CSV export? -Yes. Select **Current view** to download a CSV that matches the columns currently shown on the page. Adjust which columns appear by selecting **Display** > **Edit columns**. The other export templates use a fixed set of columns. +Yes. Select **Export** > **Current view** to download a CSV that matches the columns currently shown on the page. Adjust which columns appear by selecting **Display** > **Edit columns**. The other export templates use a fixed set of columns. ## How do I export to an accounting integration? From 40ac3c16d32be6c8a05d531281dd5e19967a6ebd Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 2 Sep 2026 23:26:59 +0000 Subject: [PATCH 111/485] Fade the Spend skeleton out over the results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the skeleton's exit fade without reanimated's `exiting`, which on web fades an element by detaching its DOM node — this skeleton is the one on screen during a cold start, so navigating away mid-animation left React removing a node that had moved. useSearchSkeletonVisibility keeps it rendered for one fade after the query resolves, and the skeleton animates its own opacity over the results behind it. It is not hit-testable while fading, so those results stay usable. --- .../Search/SearchLoadingSkeleton.tsx | 33 ++++++++++++++----- src/hooks/useSearchSkeletonVisibility.ts | 33 +++++++++++++++++++ src/pages/Search/SearchPageNarrow/index.tsx | 12 +++++-- src/pages/Search/SearchPageWide.tsx | 7 ++-- 4 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 src/hooks/useSearchSkeletonVisibility.ts diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index 4879fb69f7f8..c3f984f5f317 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -7,29 +7,46 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; -import React from 'react'; -import {StyleSheet, View} from 'react-native'; +import React, {useEffect} from 'react'; +import {StyleSheet} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; type SearchLoadingSkeletonProps = { + /** Whether the query still has no results. Turning false fades this out over the results behind it. */ + isLoading: boolean; + containerStyle?: StyleProp; }; -function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { +function SearchLoadingSkeleton({isLoading, containerStyle}: SearchLoadingSkeletonProps) { + const opacity = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => ({opacity: opacity.get()})); + + useEffect(() => { + if (isLoading) { + opacity.set(1); + return; + } + + opacity.set(withTiming(0, {duration: CONST.SEARCH.ANIMATION.FADE_DURATION})); + }, [isLoading, opacity]); + return ( // Absolutely filled so it never shares the parent's column layout with the incoming results, which would - // halve both heights for a frame. - { endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.COLD); }} > - + ); } diff --git a/src/hooks/useSearchSkeletonVisibility.ts b/src/hooks/useSearchSkeletonVisibility.ts new file mode 100644 index 000000000000..29270074349a --- /dev/null +++ b/src/hooks/useSearchSkeletonVisibility.ts @@ -0,0 +1,33 @@ +import CONST from '@src/CONST'; + +import {useEffect, useState} from 'react'; + +/** + * Keeps the search skeleton rendered for one fade after its query resolves, so it can animate out over the results + * that replaced it. + * + * Reanimated's `exiting` would do this too, but on web it fades an element by detaching its DOM node, and this + * skeleton is the one on screen during a cold start — navigating away mid-animation left React removing a node that + * had moved, throwing NotFoundError. + */ +function useSearchSkeletonVisibility(isLoading: boolean) { + const [shouldRender, setShouldRender] = useState(isLoading); + + // Adjusted during rendering because the value is consumed in the same render; the guard bounds it to one pass. + if (isLoading && !shouldRender) { + setShouldRender(true); + } + + useEffect(() => { + if (isLoading || !shouldRender) { + return; + } + + const timeoutID = setTimeout(() => setShouldRender(false), CONST.SEARCH.ANIMATION.FADE_DURATION); + return () => clearTimeout(timeoutID); + }, [isLoading, shouldRender]); + + return shouldRender; +} + +export default useSearchSkeletonVisibility; diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 24087a7b0d2b..069183d4d97c 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -22,6 +22,7 @@ import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSearchLoadingState from '@hooks/useSearchLoadingState'; +import useSearchSkeletonVisibility from '@hooks/useSearchSkeletonVisibility'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -88,6 +89,7 @@ function SearchPageNarrow({ isOverlayActive, }: SearchPageNarrowProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(contentQueryJSON, contentSearchResults); + const shouldRenderLoadingSkeleton = useSearchSkeletonVisibility(shouldShowLoadingSkeleton); // A layer replacing results already on screen renders its hydrate placeholder invisibly, since a skeleton there // reads as a flash between two sets of results. @@ -357,9 +359,13 @@ function SearchPageNarrow({ entering={FadeIn.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)} style={StyleSheet.absoluteFill} > - {shouldShowLoadingSkeleton ? ( - - ) : ( + {shouldRenderLoadingSkeleton && ( + + )} + {!shouldShowLoadingSkeleton && ( - {shouldShowLoadingSkeleton ? ( - - ) : ( + {shouldRenderLoadingSkeleton && } + {!shouldShowLoadingSkeleton && ( Date: Thu, 3 Sep 2026 01:05:16 +0000 Subject: [PATCH 112/485] Pair held Search results with the contexts their rows read The query and results providers derive from the live route params, so they advance to the requested query while its snapshot is still empty. Rows read those contexts directly for their snapshot report, policy and actions, so a held row was paired with a hash that had no data. Re-provide both contexts around the results layer and the selection footer with the pair actually rendered. --- .../Search/SearchContentContextProvider.tsx | 66 ++++++++ src/pages/Search/SearchPageNarrow/index.tsx | 153 +++++++++--------- src/pages/Search/SearchPageWide.tsx | 63 ++++---- 3 files changed, 181 insertions(+), 101 deletions(-) create mode 100644 src/components/Search/SearchContentContextProvider.tsx diff --git a/src/components/Search/SearchContentContextProvider.tsx b/src/components/Search/SearchContentContextProvider.tsx new file mode 100644 index 000000000000..e32a8c3c5f17 --- /dev/null +++ b/src/components/Search/SearchContentContextProvider.tsx @@ -0,0 +1,66 @@ +import {getTransactionsByReportID, getViolationsFromSearchData, isTodoSearch} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import type {SearchResults} from '@src/types/onyx'; + +import React from 'react'; + +import type {SearchQueryContextValue, SearchQueryJSON, SearchResultsContextValue} from './types'; + +import {useSearchQueryContext, useSearchResultsContext} from './SearchContext'; +import {EMPTY_TRANSACTIONS_BY_REPORT_ID, SearchQueryContext, SearchResultsContext} from './SearchContextDefinitions'; + +type SearchContentContextProviderProps = { + /** The query whose results the subtree renders. Differs from the live route query while the page holds the previous results. */ + queryJSON: SearchQueryJSON; + + /** The results the subtree renders, paired with `queryJSON`. */ + searchResults: SearchResults | undefined; + + children: React.ReactNode; +}; + +/** + * Re-provides the query and results contexts for the pair a subtree actually renders. + * + * The providers above derive from the live route params, so they advance to the requested query as soon as it is + * requested — and its snapshot is empty until the response lands. Rows read those contexts directly for their + * snapshot report, policy and actions, and pass the hash into their action handlers. Held rows would therefore be + * paired with a hash whose snapshot has no data, and pressing Submit/Approve/Pay would act on empty inputs. + */ +function SearchContentContextProvider({queryJSON, searchResults, children}: SearchContentContextProviderProps) { + const liveQueryValue = useSearchQueryContext(); + const liveResultsValue = useSearchResultsContext(); + + const isRenderingHeldSearch = queryJSON.hash !== liveQueryValue.currentSearchHash; + const heldSearchKey = Object.values(liveQueryValue.suggestedSearches).find((search) => search.similarSearchHash === queryJSON.similarSearchHash)?.key; + const heldData = searchResults?.data; + + const queryValue: SearchQueryContextValue = isRenderingHeldSearch + ? { + ...liveQueryValue, + currentSearchHash: queryJSON.hash, + currentSimilarSearchHash: queryJSON.similarSearchHash, + currentSearchKey: heldSearchKey, + currentSearchQueryJSON: queryJSON, + } + : liveQueryValue; + + const resultsValue: SearchResultsContextValue = isRenderingHeldSearch + ? { + ...liveResultsValue, + currentSearchResults: searchResults, + currentSearchTransactionsByReportID: heldData ? getTransactionsByReportID(heldData) : EMPTY_TRANSACTIONS_BY_REPORT_ID, + currentSearchViolations: heldData ? getViolationsFromSearchData(heldData) : CONST.EMPTY_OBJECT, + shouldUseLiveData: !!heldSearchKey && isTodoSearch(queryJSON.recentSearchHash, liveQueryValue.suggestedSearches), + } + : liveResultsValue; + + return ( + + {children} + + ); +} + +export default SearchContentContextProvider; diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 069183d4d97c..3d3e842cb3de 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -7,6 +7,7 @@ import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import Search from '@components/Search'; +import SearchContentContextProvider from '@components/Search/SearchContentContextProvider'; import {useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPageHeaderNarrow'; @@ -319,81 +320,87 @@ function SearchPageNarrow({ /> )} - - {useStaticRendering && ( - <> - {isInteractive && ( - - )} - {shouldRenderLayoutProbe && } - {!!searchOverlayContent && ( - - {searchOverlayContent} - - )} - - )} - {!useStaticRendering && ( - <> - {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - - {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones + {/* The rendered pair is re-provided to the search contexts so held rows keep the snapshot they belong to. */} + + + {useStaticRendering && ( + <> + {isInteractive && ( + + )} + {shouldRenderLayoutProbe && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + {!useStaticRendering && ( + <> + {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} + + {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones stay on screen until then, and the new ones fade in. Absolutely filled so it never shares the parent's column layout with the layer it replaces. */} - - {shouldRenderLoadingSkeleton && ( - - )} - {!shouldShowLoadingSkeleton && ( - - )} - - - {shouldRenderLayoutProbe && } - {!!searchOverlayContent && ( - - {searchOverlayContent} - - )} - - )} - - + + {shouldRenderLoadingSkeleton && ( + + )} + {!shouldShowLoadingSkeleton && ( + + )} + + + {shouldRenderLayoutProbe && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + + + {(!useStaticRendering || isHeaderInteractive) && ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 861b87df8fa6..b6b07862ecea 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -3,6 +3,7 @@ import {useSearchSidebarContentOffsetStyle} from '@components/Navigation/SearchS import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; +import SearchContentContextProvider from '@components/Search/SearchContentContextProvider'; import {useSearchQueryContext, useSearchSelectionContext} from '@components/Search/SearchContext'; import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchActionsBarWide from '@components/Search/SearchPageHeader/SearchActionsBarWide'; @@ -136,36 +137,42 @@ function SearchPageWide({ searchResults={searchResults} onSort={onSortPressedCallback} /> - - {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - - {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones + {/* The rendered pair is re-provided to the search contexts so held rows keep the snapshot they belong to. */} + + + {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} + + {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones stay on screen until then, and the new ones fade in. Absolutely filled so it never shares the parent's column layout with the layer it replaces. */} - - {shouldRenderLoadingSkeleton && } - {!shouldShowLoadingSkeleton && ( - - )} - - - {!!searchOverlayContent && {searchOverlayContent}} - - + + {shouldRenderLoadingSkeleton && } + {!shouldShowLoadingSkeleton && ( + + )} + + + {!!searchOverlayContent && {searchOverlayContent}} + + + )} From 4cf871dd840c96b1466b96297352144fb436fecd Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:56:00 +0530 Subject: [PATCH 113/485] Keep search policy tags collection in memory only --- src/setup/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/setup/index.ts b/src/setup/index.ts index aa7eb90e1363..ce9d2494b3d7 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -79,6 +79,7 @@ export default function () { ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, + ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE, From 47915248a3c177ff52f6211d3c16b31f7bad37bb Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:15:52 +0530 Subject: [PATCH 114/485] Keep tag filter values without validating against incomplete local tag data --- src/hooks/useFilterFormValues.tsx | 30 +---- src/libs/SearchQueryUtils.ts | 21 +--- .../useFilterFormValues.perf-test.tsx | 70 +---------- tests/unit/Search/SearchQueryUtilsTest.ts | 119 +++++------------- tests/unit/hooks/useFilterFormValues.test.ts | 77 +----------- 5 files changed, 44 insertions(+), 273 deletions(-) diff --git a/src/hooks/useFilterFormValues.tsx b/src/hooks/useFilterFormValues.tsx index 82629739650a..e72d1abf66db 100644 --- a/src/hooks/useFilterFormValues.tsx +++ b/src/hooks/useFilterFormValues.tsx @@ -8,7 +8,7 @@ import {buildFilterFormValuesFromQuery} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; -import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection} from 'react-native-onyx'; @@ -52,28 +52,6 @@ function policyCategoriesSelector(categories: OnyxCollection): return result; } -function policyTagsSelector(tags: OnyxCollection): OnyxCollection { - if (!tags) { - return tags; - } - const result: OnyxCollection = {}; - for (const [collectionKey, policyTagLists] of Object.entries(tags)) { - if (!policyTagLists) { - continue; - } - const minimalTagLists: PolicyTagLists = {}; - for (const [listKey, tagList] of Object.entries(policyTagLists)) { - const minimalTags: Record = {}; - for (const [tagKey, tag] of Object.entries(tagList.tags ?? {})) { - minimalTags[tagKey] = {name: tag.name}; - } - minimalTagLists[listKey] = {tags: minimalTags} as PolicyTagLists[string]; - } - result[collectionKey] = minimalTagLists; - } - return result; -} - const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { const personalDetails = usePersonalDetails(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -84,8 +62,6 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { // Subscribe to the report collection directly. buildFilterFormValuesFromQuery only does keyed existence // lookups (`in:` filter), so projecting via a selector would just waste an O(n) pass over every report. const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: policyTagsSelector}); - const [searchPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {selector: policyCategoriesSelector}); const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); @@ -99,7 +75,6 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { ? buildFilterFormValuesFromQuery( queryJSON, policyCategories, - policyTagsLists, currencyList, personalDetails, allCards, @@ -108,7 +83,6 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { exportedToFilterOptions, currentUserPersonalDetails.accountID, bankAccountList, - searchPolicyTags, ) : getEmptyObject>(); @@ -116,4 +90,4 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { }; export default useFilterFormValues; -export {policiesSelector, policyCategoriesSelector, policyTagsSelector}; +export {policiesSelector, policyCategoriesSelector}; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 745cc620a7ff..a3c41566e1b9 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1343,7 +1343,6 @@ function getDateRangeForPreset(preset: SearchDatePreset): {start: string; end: s function buildFilterFormValuesFromQuery( queryJSON: SearchQueryJSON, policyCategories: OnyxCollection, - policyTags: OnyxCollection, currencyList: OnyxTypes.CurrencyList, personalDetails: OnyxTypes.PersonalDetailsList | undefined, cardList: OnyxTypes.CardList | undefined, @@ -1352,7 +1351,6 @@ function buildFilterFormValuesFromQuery( exportedToFilterOptions?: string[], currentUserAccountID?: number, bankAccountList?: OnyxTypes.BankAccountList, - searchPolicyTags?: OnyxCollection, ) { const filters = queryJSON.flatFilters; const filtersForm = {} as Partial; @@ -1480,23 +1478,8 @@ function buildFilterFormValuesFromQuery( filtersForm[filterKey] = filterValues.find((currency) => validCurrencies.has(currency)); } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG) { - const uniqueTags = new Set(); - const tagLists = getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_TAGS, policyTags); - for (const tagList of tagLists) { - for (const policyTagList of Object.values(tagList ?? {})) { - for (const tag of Object.values(policyTagList.tags ?? {})) { - uniqueTags.add(tag.name); - } - } - } - // Tags fetched through the paginated tag filter endpoint may not exist in the full policy tags cache - for (const searchTags of Object.values(searchPolicyTags ?? {})) { - for (const tag of Object.values(searchTags ?? {})) { - uniqueTags.add(tag.tagName); - } - } - uniqueTags.add(CONST.SEARCH.TAG_EMPTY_VALUE); - filtersForm[addNegation(filterKey, isNegated)] = filterValues.filter((name) => uniqueTags.has(name)); + // Tag values are kept as-is: with server-side tag pagination the local tag data is never complete, so validating against it would silently drop valid tags + filtersForm[addNegation(filterKey, isNegated)] = filterValues; } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) { const uniqueCategories = new Set(); diff --git a/tests/perf-test/useFilterFormValues.perf-test.tsx b/tests/perf-test/useFilterFormValues.perf-test.tsx index 74e5f572338d..0810e34f6931 100644 --- a/tests/perf-test/useFilterFormValues.perf-test.tsx +++ b/tests/perf-test/useFilterFormValues.perf-test.tsx @@ -2,14 +2,14 @@ import {typeOptionsPoliciesSelector} from '@components/Search/FilterComponents/T import {advancedSearchPoliciesSelector} from '@hooks/useAdvancedSearchFilters'; import {exportedToPoliciesSelector} from '@hooks/useExportedToFilterOptions'; -import {policiesSelector, policyCategoriesSelector, policyTagsSelector} from '@hooks/useFilterFormValues'; +import {policiesSelector, policyCategoriesSelector} from '@hooks/useFilterFormValues'; import {getAllTaxRates} from '@libs/PolicyUtils'; import {buildFilterFormValuesFromQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import type {SearchQueryJSON} from '@src/components/Search/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, PolicyTagLists, Report} from '@src/types/onyx'; +import type {Policy, PolicyCategories, Report} from '@src/types/onyx'; import React, {useMemo} from 'react'; import {View} from 'react-native'; @@ -34,7 +34,6 @@ function buildSearchQueryJSONOrThrow(query: string): SearchQueryJSON { const POLICY_COUNT = 500; const REPORT_COUNT = 500; const CATEGORY_COUNT = 500; -const TAG_COUNT = 500; beforeAll(() => Onyx.init({keys: ONYXKEYS})); @@ -84,23 +83,6 @@ describe('useFilterFormValues', () => { await measureFunction(() => policyCategoriesSelector(categories)); }); - test('policyTagsSelector with 500 policy tag collections', async () => { - const tags = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${index}`, - () => - createMock({ - Department: { - name: 'Department', - required: true, - tags: Object.fromEntries(Array.from({length: 30}, (_unused, i) => [`Tag${i}`, {name: `Tag${i}`, enabled: true}])), - }, - }), - TAG_COUNT, - ); - - await measureFunction(() => policyTagsSelector(tags)); - }); - test('exportedToPoliciesSelector with 500 policies', async () => { const policies = createCollection( (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, @@ -171,7 +153,7 @@ describe('useFilterFormValues', () => { }); describe('buildFilterFormValuesFromQuery execution', () => { - test('buildFilterFormValuesFromQuery with 500 policies, reports, categories, tags', async () => { + test('buildFilterFormValuesFromQuery with 500 policies, reports, categories', async () => { const queryJSON = buildSearchQueryJSONOrThrow('type:expense status:all category:Category0 tag:Tag0'); const policies = createCollection( @@ -198,21 +180,9 @@ describe('useFilterFormValues', () => { () => createMock(Object.fromEntries(Array.from({length: 40}, (_unused, i) => [`Category${i}`, {name: `Category${i}`, enabled: true}]))), CATEGORY_COUNT, ); - const tags = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${index}`, - () => - createMock({ - Department: { - name: 'Department', - required: true, - tags: Object.fromEntries(Array.from({length: 30}, (_unused, i) => [`Tag${i}`, {name: `Tag${i}`, enabled: true}])), - }, - }), - TAG_COUNT, - ); const taxRates = getAllTaxRates(policies); - await measureFunction(() => buildFilterFormValuesFromQuery(queryJSON, categories, tags, {}, {}, {}, reports, taxRates)); + await measureFunction(() => buildFilterFormValuesFromQuery(queryJSON, categories, {}, {}, {}, reports, taxRates)); }); }); @@ -223,10 +193,9 @@ describe('useFilterFormValues', () => { function TestComponent() { const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesSelector}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: policyTagsSelector}); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {selector: policyCategoriesSelector}); const taxRates = useMemo(() => getAllTaxRates(policies), [policies]); - const formValues = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, {}, {}, {}, allReports, taxRates); + const formValues = buildFilterFormValuesFromQuery(queryJSON, policyCategories, {}, {}, {}, allReports, taxRates); return ; } @@ -243,18 +212,6 @@ describe('useFilterFormValues', () => { () => createMock(Object.fromEntries(Array.from({length: 40}, (_unused, i) => [`Category${i}`, {name: `Category${i}`, enabled: true}]))), CATEGORY_COUNT, ); - const tags = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${index}`, - () => - createMock({ - Department: { - name: 'Department', - required: true, - tags: Object.fromEntries(Array.from({length: 30}, (_unused, i) => [`Tag${i}`, {name: `Tag${i}`, enabled: true}])), - }, - }), - TAG_COUNT, - ); const reports = createCollection( (_, index) => `${ONYXKEYS.COLLECTION.REPORT}${index}`, (index) => createMock({reportID: `${index}`, reportName: `Report ${index}`}), @@ -262,7 +219,6 @@ describe('useFilterFormValues', () => { ); await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY, policies); await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, categories); - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY_TAGS, tags); await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, reports); await waitForBatchedUpdates(); @@ -282,10 +238,9 @@ describe('useFilterFormValues', () => { function TestComponent() { const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const taxRates = useMemo(() => getAllTaxRates(policies), [policies]); - const formValues = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, {}, {}, {}, allReports, taxRates); + const formValues = buildFilterFormValuesFromQuery(queryJSON, policyCategories, {}, {}, {}, allReports, taxRates); return ; } @@ -302,18 +257,6 @@ describe('useFilterFormValues', () => { () => createMock(Object.fromEntries(Array.from({length: 40}, (_unused, i) => [`Category${i}`, {name: `Category${i}`, enabled: true}]))), CATEGORY_COUNT, ); - const tags = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${index}`, - () => - createMock({ - Department: { - name: 'Department', - required: true, - tags: Object.fromEntries(Array.from({length: 30}, (_unused, i) => [`Tag${i}`, {name: `Tag${i}`, enabled: true}])), - }, - }), - TAG_COUNT, - ); const reports = createCollection( (_, index) => `${ONYXKEYS.COLLECTION.REPORT}${index}`, (index) => createMock({reportID: `${index}`, reportName: `Report ${index}`}), @@ -321,7 +264,6 @@ describe('useFilterFormValues', () => { ); await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY, policies); await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, categories); - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY_TAGS, tags); await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, reports); await waitForBatchedUpdates(); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index fb757dd45cd8..112c12a99523 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -1165,7 +1165,6 @@ describe('SearchQueryUtils', () => { }, }, }; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1176,7 +1175,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result).toEqual({ type: 'expense', @@ -1186,7 +1185,6 @@ describe('SearchQueryUtils', () => { test('action filter should be set to undefined if the input value is invalid', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1200,7 +1198,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - let result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + let result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result).toEqual({ type: 'expense', @@ -1215,7 +1213,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result).toEqual({ type: 'expense', @@ -1230,7 +1228,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); expect(result).toMatchObject({ type: 'expense-report', @@ -1241,7 +1239,6 @@ describe('SearchQueryUtils', () => { test('withdrawal status filter parses valid values and drops invalid ones', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1255,7 +1252,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - let result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + let result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result).toEqual({ type: 'expense', @@ -1270,7 +1267,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result).toEqual({ type: 'expense', @@ -1280,7 +1277,6 @@ describe('SearchQueryUtils', () => { test('paid status filter parses valid values and drops invalid ones', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1294,7 +1290,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - let result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + let result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result).toEqual({ type: 'expense-report', @@ -1309,7 +1305,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result).toEqual({ type: 'expense-report', @@ -1319,7 +1315,6 @@ describe('SearchQueryUtils', () => { test('parses negative backend amounts into filter form values', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1333,7 +1328,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result).toEqual({ type: 'expense', @@ -1345,7 +1340,6 @@ describe('SearchQueryUtils', () => { test('attendee filter preserves name-only attendees without filtering by personalDetails', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = { 12345: {accountID: 12345, login: 'user@example.com'}, @@ -1362,7 +1356,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); // Both values should be preserved - name-only attendees should not be filtered out expect(result).toEqual({ @@ -1373,7 +1367,6 @@ describe('SearchQueryUtils', () => { test('hydrates explicit date range flag from inclusive range boundaries', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1386,7 +1379,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result.dateAfter).toBeUndefined(); expect(result.dateBefore).toBeUndefined(); @@ -1395,7 +1388,6 @@ describe('SearchQueryUtils', () => { test('does not set explicit date range flag when only date boundaries are provided', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1408,7 +1400,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result.dateRange).toBeUndefined(); }); @@ -1431,7 +1423,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); expect(result.dateOn).toBeUndefined(); expect(result.dateAfter).toBeUndefined(); expect(result.dateBefore).toBeUndefined(); @@ -1446,7 +1438,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); expect(result.dateOn).toBeUndefined(); expect(result.dateAfter).toBeUndefined(); expect(result.dateBefore).toBeUndefined(); @@ -1461,7 +1453,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); expect(result.approvedOn).toBeUndefined(); expect(result.approvedAfter).toBeUndefined(); expect(result.approvedBefore).toBeUndefined(); @@ -1476,7 +1468,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); expect(result.dateOn).toBeUndefined(); expect(result.dateAfter).toBeUndefined(); expect(result.dateBefore).toBeUndefined(); @@ -1491,7 +1483,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); expect(result.dateOn).toBe('last-12-months'); expect(result.dateRange).toBeUndefined(); }); @@ -1504,14 +1496,13 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); expect(result.dateRange).toBe('2026-04-01,2026-04-15'); }); }); test('hydrates explicit report field range flag from inclusive range boundaries', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1524,7 +1515,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result['reportFieldAfter-start-date']).toBeUndefined(); expect(result['reportFieldBefore-start-date']).toBeUndefined(); @@ -1533,7 +1524,6 @@ describe('SearchQueryUtils', () => { test('does not set explicit report field range flag when only date boundaries are provided', () => { const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1546,7 +1536,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result['reportFieldRange-start-date']).toBeUndefined(); }); @@ -1554,7 +1544,6 @@ describe('SearchQueryUtils', () => { describe('view parameter', () => { const emptyParams = { policyCategories: {}, - policyTags: {}, currencyList: {}, personalDetails: {}, cardList: {}, @@ -1573,7 +1562,6 @@ describe('SearchQueryUtils', () => { const result = buildFilterFormValuesFromQuery( queryJSON, emptyParams.policyCategories, - emptyParams.policyTags, emptyParams.currencyList, emptyParams.personalDetails, emptyParams.cardList, @@ -1596,7 +1584,6 @@ describe('SearchQueryUtils', () => { const result = buildFilterFormValuesFromQuery( queryJSON, emptyParams.policyCategories, - emptyParams.policyTags, emptyParams.currencyList, emptyParams.personalDetails, emptyParams.cardList, @@ -1610,24 +1597,12 @@ describe('SearchQueryUtils', () => { }); }); - test('tag filter validates against policy tags', () => { + test('tag filter keeps values without validating against local tag data', () => { const policyID = generatePolicyID(); const queryString = `sortBy:date sortOrder:desc type:expense tag:Engineering,Marketing,NonExistent policyID:${policyID}`; const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = createMock>({ - [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]: { - Department: { - name: 'Department', - tags: { - Engineering: {name: 'Engineering', enabled: true}, - Marketing: {name: 'Marketing', enabled: true}, - Sales: {name: 'Sales', enabled: true}, - }, - }, - }, - }); const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1638,10 +1613,10 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); - // NonExistent should be filtered out since it's not in any policy's tags - expect(result.tag).toEqual(['Engineering', 'Marketing']); + // Local tag data is never complete with server-side tag pagination, so all values are kept + expect(result.tag).toEqual(['Engineering', 'Marketing', 'NonExistent']); }); test('currency filter validates against currency list', () => { @@ -1649,7 +1624,6 @@ describe('SearchQueryUtils', () => { const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = {}; const currencyList = createMock({USD: {}, EUR: {}, GBP: {}}); const personalDetails = {}; const cardList = {}; @@ -1660,7 +1634,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); // INVALID should be filtered out expect(result.currency).toEqual(['USD', 'EUR']); @@ -1671,7 +1645,6 @@ describe('SearchQueryUtils', () => { const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1682,7 +1655,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); // nonexistent should be filtered out expect(result.taxRate).toEqual(['id_vat', 'id_gst']); @@ -1693,7 +1666,6 @@ describe('SearchQueryUtils', () => { const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1704,7 +1676,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); // invalid should be filtered out, cash and card are valid CONST.SEARCH.TRANSACTION_TYPE values expect(result.expenseType).toEqual(['cash', 'card']); @@ -1715,7 +1687,6 @@ describe('SearchQueryUtils', () => { const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1726,7 +1697,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); // invalid should be filtered out, ereceipt and hotel are valid CONST.SEARCH.RECEIPT_TYPE values expect(result.receiptType).toEqual(['ereceipt', 'hotel']); @@ -1738,7 +1709,6 @@ describe('SearchQueryUtils', () => { const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = {}; const cardList = {}; @@ -1749,7 +1719,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result.receiptTypeNot).toEqual(['hotel']); expect(result.receiptType).toBeUndefined(); @@ -1761,7 +1731,6 @@ describe('SearchQueryUtils', () => { const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = { '12345': { @@ -1778,18 +1747,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery( - queryJSON, - policyCategories, - policyTags, - currencyList, - personalDetails, - cardList, - reports, - taxRates, - undefined, - currentUserAccountID, - ); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates, undefined, currentUserAccountID); expect(result.from).toEqual(['12345']); }); @@ -1799,7 +1757,6 @@ describe('SearchQueryUtils', () => { const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = { '12345': { @@ -1816,7 +1773,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates); expect(result.from).toEqual([]); }); @@ -1827,7 +1784,6 @@ describe('SearchQueryUtils', () => { const queryJSON = buildSearchQueryJSON(queryString); const policyCategories = {}; - const policyTags = {}; const currencyList = {}; const personalDetails = { '99999': { @@ -1844,18 +1800,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery( - queryJSON, - policyCategories, - policyTags, - currencyList, - personalDetails, - cardList, - reports, - taxRates, - undefined, - currentUserAccountID, - ); + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, currencyList, personalDetails, cardList, reports, taxRates, undefined, currentUserAccountID); expect(result.to).toEqual(['99999']); }); diff --git a/tests/unit/hooks/useFilterFormValues.test.ts b/tests/unit/hooks/useFilterFormValues.test.ts index bf11e06d459c..849dd4b865c4 100644 --- a/tests/unit/hooks/useFilterFormValues.test.ts +++ b/tests/unit/hooks/useFilterFormValues.test.ts @@ -2,10 +2,10 @@ import {typeOptionsPoliciesSelector} from '@components/Search/FilterComponents/T import {advancedSearchPoliciesSelector} from '@hooks/useAdvancedSearchFilters'; import {exportedToPoliciesSelector} from '@hooks/useExportedToFilterOptions'; -import {policiesSelector, policyCategoriesSelector, policyTagsSelector} from '@hooks/useFilterFormValues'; +import {policiesSelector, policyCategoriesSelector} from '@hooks/useFilterFormValues'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, PolicyReportField, PolicyTagLists} from '@src/types/onyx'; +import type {Policy, PolicyCategories, PolicyReportField} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; @@ -15,8 +15,6 @@ const POLICY_KEY = `${ONYXKEYS.COLLECTION.POLICY}1`; const POLICY_KEY_2 = `${ONYXKEYS.COLLECTION.POLICY}2`; const CATEGORIES_KEY = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1`; const CATEGORIES_KEY_2 = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}2`; -const TAGS_KEY = `${ONYXKEYS.COLLECTION.POLICY_TAGS}1`; -const TAGS_KEY_2 = `${ONYXKEYS.COLLECTION.POLICY_TAGS}2`; describe('useFilterFormValues selectors', () => { describe('policiesSelector', () => { @@ -90,77 +88,6 @@ describe('useFilterFormValues selectors', () => { }); }); - describe('policyTagsSelector', () => { - it('returns undefined input as-is', () => { - expect(policyTagsSelector(undefined)).toBeUndefined(); - }); - - it('extracts only tag names', () => { - const tags: OnyxCollection = { - [TAGS_KEY]: createMock({ - Department: { - name: 'Department', - required: true, - tags: { - Engineering: {name: 'Engineering', enabled: true}, - Marketing: {name: 'Marketing', enabled: false}, - }, - }, - }), - }; - - const result = policyTagsSelector(tags); - - expect(result?.[TAGS_KEY]).toEqual({ - Department: { - tags: { - Engineering: {name: 'Engineering'}, - Marketing: {name: 'Marketing'}, - }, - }, - }); - }); - - it('skips undefined collection entries', () => { - const tags: OnyxCollection = { - [TAGS_KEY]: createMock({ - Department: { - tags: { - Engineering: {name: 'Engineering'}, - }, - }, - }), - [TAGS_KEY_2]: undefined, - }; - - const result = policyTagsSelector(tags); - - expect(result).toEqual({ - [TAGS_KEY]: { - Department: {tags: {Engineering: {name: 'Engineering'}}}, - }, - }); - expect(result).not.toHaveProperty(TAGS_KEY_2); - }); - - it('handles tag lists with empty tags object', () => { - const tags: OnyxCollection = { - [TAGS_KEY]: createMock({ - Department: { - name: 'Department', - tags: {}, - }, - }), - }; - - const result = policyTagsSelector(tags); - - expect(result?.[TAGS_KEY]).toEqual({ - Department: {tags: {}}, - }); - }); - }); - describe('exportedToPoliciesSelector', () => { it('returns undefined input as-is', () => { expect(exportedToPoliciesSelector(undefined)).toBeUndefined(); From b7a42d8f669fb43178640838fcb4002943a0961f Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:30:12 +0530 Subject: [PATCH 115/485] Fix tag order after clearing search --- src/hooks/useSearchTagFilters.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 45f029c34994..94c62c03d931 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -119,10 +119,13 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { }); }; - // Fetch the first page on mount; cached results are shown immediately and refreshed in the background + // Fetch the first page on mount and when the workspace scope changes. + // searchTags is not memoized, so it cannot be in the dependency array — it would fire on every render. + // searchTags reads latest state via refs, so the closure captured here is safe to call. useEffect(() => { searchTags(''); - }, [searchTags]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [policyIDs]); const isInitialLoading = isLoading && !hasCompletedSearch; From fb027dc91a123c31fa6a64e56cfc00f4718cc4bb Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:37:14 +0530 Subject: [PATCH 116/485] Clear stale tag results on new search to prevent list reordering --- src/libs/actions/Search.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index fccfa48410af..2cadb8f89bb7 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1061,6 +1061,9 @@ type OpenSearchTagFiltersPageResponse = { function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams, shouldCancelPendingRequests = false): Promise<{hasMore: boolean; nextCursor: string}> { if (shouldCancelPendingRequests) { HttpUtils.cancelPendingRequests(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE); + // Clear stale results immediately so the selected-tags fallback in TagSelector doesn't append + // selected tags after stale search results, which would reorder the list before the API responds. + Onyx.setCollection(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, {}); } return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}).then((response) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OpenSearchTagFiltersPage response fields are command-specific and not declared on the shared Response type From dae8d12ee99f21ccc8cf1186c0a7f5224c2a91c0 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:39:24 +0530 Subject: [PATCH 117/485] Show loading state instead of no results during tag search --- src/components/Search/FilterComponents/TagSelector.tsx | 5 +++-- src/hooks/useSearchTagFilters.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 048e350f4fca..ab2683cd4a27 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -49,8 +49,9 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti return {text: getCleanedTagName(tag), value: tag}; }); - // Selected tags that are not in the current result page stay visible once the search is cleared, so the selection doesn't disappear from the list - if (!searchQuery) { + // Selected tags that are not in the current result page stay visible once the search is cleared, so the selection doesn't disappear from the list. + // Gated on !isLoading so stale results during a fetch don't cause selected tags to be appended in the wrong order. + if (!searchQuery && !isLoading) { const itemValues = new Set(tagItems.map((item) => item.value)); for (const selectedItem of selectedTagsItems) { if (!itemValues.has(selectedItem.value)) { diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 94c62c03d931..166ed7b59035 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -102,8 +102,8 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { setSearchTagFiltersPagination(false, '', query); // A new search cancels any in-flight request, so it owns the loading state from here on. - // With cached data the fetch runs silently in the background. - setIsLoading(!currentHasCachedData); + // The collection is cleared by openSearchTagFiltersPage, so there is no cached data to show silently. + setIsLoading(true); openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE, policyIDs}, true) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { From 1b726aca1be90b209dbc5b428bdb7e36c8231ac2 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:44:06 +0530 Subject: [PATCH 118/485] Suppress no results message during tag search loading --- src/components/Search/FilterComponents/MultiSelect.tsx | 6 +++++- src/components/Search/FilterComponents/TagSelector.tsx | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index b43c57027f2a..d9a39c5dece8 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -62,6 +62,9 @@ type MultiSelectProps = SearchFilterCommonProps>> & /** Whether more items are being loaded (shows spinner at bottom of list) */ isLoadingMore?: boolean; + + /** Whether a search is in progress (suppresses "No results found" while true) */ + isLoading?: boolean; }; function MultiSelect({ @@ -81,6 +84,7 @@ function MultiSelect({ onEndReached, onSearchChange, isLoadingMore, + isLoading, }: MultiSelectProps) { const theme = useTheme(); const {translate} = useLocalize(); @@ -122,7 +126,7 @@ function MultiSelect({ leftElement: item.leftElement, })); - const headerMessage = isSearchable && listData.length === 0 ? translate('common.noResultsFound') : undefined; + const headerMessage = isSearchable && listData.length === 0 && !isLoading ? translate('common.noResultsFound') : undefined; const updateSelectedItems = (item: ListItem) => { if (item.isSelected) { diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index ab2683cd4a27..c63fad27fcc2 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -73,6 +73,7 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti onEndReached={hasMore ? loadMore : undefined} onSearchChange={searchTags} loading={isInitialLoading} + isLoading={isLoading} isLoadingMore={isLoading && tagItems.length > 1} /> ); From 26c2551ba121f22873a27b9b5729c6e6d477ff38 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:48:03 +0530 Subject: [PATCH 119/485] Show loading spinner during tag search when list is empty --- src/components/Search/FilterComponents/TagSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index c63fad27fcc2..e97b48c65b95 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -74,7 +74,7 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti onSearchChange={searchTags} loading={isInitialLoading} isLoading={isLoading} - isLoadingMore={isLoading && tagItems.length > 1} + isLoadingMore={isLoading} /> ); } From c9f17e4fcbbf854484b4fddaf86ee9b7636c6b66 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:00:24 +0530 Subject: [PATCH 120/485] Fix CI: remove unused var, defer effect setState, remove stale eslint-disable --- src/hooks/useSearchTagFilters.ts | 4 ++-- src/libs/SearchQueryUtils.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 166ed7b59035..4aca0f0504e4 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -95,7 +95,6 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { }; const searchTags = (query: string) => { - const {hasCachedData: currentHasCachedData} = stateRef.current; const requestSeq = ++requestSeqRef.current; // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor @@ -123,7 +122,8 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // searchTags is not memoized, so it cannot be in the dependency array — it would fire on every render. // searchTags reads latest state via refs, so the closure captured here is safe to call. useEffect(() => { - searchTags(''); + // Defer to a microtask so setState calls inside searchTags don't fire synchronously within the effect body + Promise.resolve().then(() => searchTags('')); // eslint-disable-next-line react-hooks/exhaustive-deps }, [policyIDs]); diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index a3c41566e1b9..518ac91a9f60 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1339,7 +1339,6 @@ function getDateRangeForPreset(preset: SearchDatePreset): {start: string; end: s * Reverse operation of buildQueryStringFromFilterFormValues() */ // Adds bankAccountList and currentUserAccountID for the new bank account and from:me filters. Refactoring this to a params object would touch every call site and is out of scope here. -// eslint-disable-next-line @typescript-eslint/max-params function buildFilterFormValuesFromQuery( queryJSON: SearchQueryJSON, policyCategories: OnyxCollection, From 099b4d97ea5bceb99c0dc319426ef06e1da4798c Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:52:12 +0530 Subject: [PATCH 121/485] Skip tag filter fetch while offline, refetch on reconnect --- src/hooks/useSearchTagFilters.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 4aca0f0504e4..03e4449e75e4 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -9,6 +9,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import {useEffect, useRef, useState} from 'react'; +import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; type UseSearchTagFiltersResult = { @@ -49,6 +50,7 @@ function logRequestFailure(message: string, error: Error) { * Accepts a comma-separated list of policy IDs to scope results to specific workspaces. */ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { + const {isOffline} = useNetwork(); const [searchResults] = useOnyx(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS); const [paginationState] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION); const [isLoading, setIsLoading] = useState(false); @@ -76,7 +78,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { const loadMore = () => { const {hasMore: currentHasMore, nextCursor: currentCursor, searchQuery: currentQuery, isLoading: currentIsLoading} = stateRef.current; - if (currentIsLoading || !currentHasMore) { + if (currentIsLoading || !currentHasMore || isOffline) { return; } const requestSeq = requestSeqRef.current; @@ -95,6 +97,9 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { }; const searchTags = (query: string) => { + if (isOffline) { + return; + } const requestSeq = ++requestSeqRef.current; // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor @@ -119,13 +124,17 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { }; // Fetch the first page on mount and when the workspace scope changes. + // Skips the fetch while offline and refetches on reconnect, matching useLoadSearchCategoryData. // searchTags is not memoized, so it cannot be in the dependency array — it would fire on every render. // searchTags reads latest state via refs, so the closure captured here is safe to call. useEffect(() => { + if (isOffline) { + return; + } // Defer to a microtask so setState calls inside searchTags don't fire synchronously within the effect body Promise.resolve().then(() => searchTags('')); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [policyIDs]); + }, [policyIDs, isOffline]); const isInitialLoading = isLoading && !hasCompletedSearch; From 23ba088e2906f3f33b033cf2c7ae3d9dd115f388 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:55:33 +0530 Subject: [PATCH 122/485] Filter cached tags locally when offline --- src/components/Search/FilterComponents/TagSelector.tsx | 7 ++++++- src/hooks/useSearchTagFilters.ts | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index e97b48c65b95..04e96a8f32ad 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -30,13 +30,18 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti tagItems.push(emptyTagItem); } const seenTagNames = new Set(); + const lowerSearchQuery = searchQuery.toLowerCase(); - // Preserve backend order - new items append at end for infinite scroll + // Preserve backend order - new items append at end for infinite scroll. + // When offline the API is skipped, so cached results are filtered locally by the search query. for (const policyTags of Object.values(searchResults ?? {})) { for (const tag of Object.values(policyTags ?? {})) { if (seenTagNames.has(tag.tagName)) { continue; } + if (lowerSearchQuery && !tag.tagName.toLowerCase().includes(lowerSearchQuery)) { + continue; + } seenTagNames.add(tag.tagName); tagItems.push({text: getCleanedTagName(tag.tagName), value: tag.tagName}); } diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 03e4449e75e4..d0f0a78a44ef 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -98,6 +98,8 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { const searchTags = (query: string) => { if (isOffline) { + // When offline, update the search query so TagSelector can filter cached results locally + setSearchTagFiltersPagination(false, '', query); return; } const requestSeq = ++requestSeqRef.current; From ede868361554d4b3a8c1599716408e4cd8c700ad Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:58:09 +0530 Subject: [PATCH 123/485] Fix typo: refetches to re-fetches --- src/hooks/useSearchTagFilters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index d0f0a78a44ef..e76ba081db3b 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -126,7 +126,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { }; // Fetch the first page on mount and when the workspace scope changes. - // Skips the fetch while offline and refetches on reconnect, matching useLoadSearchCategoryData. + // Skips the fetch while offline and re-fetches on reconnect, matching useLoadSearchCategoryData. // searchTags is not memoized, so it cannot be in the dependency array — it would fire on every render. // searchTags reads latest state via refs, so the closure captured here is safe to call. useEffect(() => { From c18e32b9a67edd6207970c696cc9baf6c11c3538 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Thu, 3 Sep 2026 21:37:11 +0000 Subject: [PATCH 124/485] Revert "Pair held Search results with the contexts their rows read" This reverts commit 3bfbfc39503. A hold can only start with nothing selected, since the filter bar, sort and column controls are all replaced by the bulk actions button once anything is selected. That left only a sub-500ms window for a row action button, which is not worth re-providing both search contexts for. --- .../Search/SearchContentContextProvider.tsx | 66 -------- src/pages/Search/SearchPageNarrow/index.tsx | 153 +++++++++--------- src/pages/Search/SearchPageWide.tsx | 63 ++++---- 3 files changed, 101 insertions(+), 181 deletions(-) delete mode 100644 src/components/Search/SearchContentContextProvider.tsx diff --git a/src/components/Search/SearchContentContextProvider.tsx b/src/components/Search/SearchContentContextProvider.tsx deleted file mode 100644 index e32a8c3c5f17..000000000000 --- a/src/components/Search/SearchContentContextProvider.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import {getTransactionsByReportID, getViolationsFromSearchData, isTodoSearch} from '@libs/SearchUIUtils'; - -import CONST from '@src/CONST'; -import type {SearchResults} from '@src/types/onyx'; - -import React from 'react'; - -import type {SearchQueryContextValue, SearchQueryJSON, SearchResultsContextValue} from './types'; - -import {useSearchQueryContext, useSearchResultsContext} from './SearchContext'; -import {EMPTY_TRANSACTIONS_BY_REPORT_ID, SearchQueryContext, SearchResultsContext} from './SearchContextDefinitions'; - -type SearchContentContextProviderProps = { - /** The query whose results the subtree renders. Differs from the live route query while the page holds the previous results. */ - queryJSON: SearchQueryJSON; - - /** The results the subtree renders, paired with `queryJSON`. */ - searchResults: SearchResults | undefined; - - children: React.ReactNode; -}; - -/** - * Re-provides the query and results contexts for the pair a subtree actually renders. - * - * The providers above derive from the live route params, so they advance to the requested query as soon as it is - * requested — and its snapshot is empty until the response lands. Rows read those contexts directly for their - * snapshot report, policy and actions, and pass the hash into their action handlers. Held rows would therefore be - * paired with a hash whose snapshot has no data, and pressing Submit/Approve/Pay would act on empty inputs. - */ -function SearchContentContextProvider({queryJSON, searchResults, children}: SearchContentContextProviderProps) { - const liveQueryValue = useSearchQueryContext(); - const liveResultsValue = useSearchResultsContext(); - - const isRenderingHeldSearch = queryJSON.hash !== liveQueryValue.currentSearchHash; - const heldSearchKey = Object.values(liveQueryValue.suggestedSearches).find((search) => search.similarSearchHash === queryJSON.similarSearchHash)?.key; - const heldData = searchResults?.data; - - const queryValue: SearchQueryContextValue = isRenderingHeldSearch - ? { - ...liveQueryValue, - currentSearchHash: queryJSON.hash, - currentSimilarSearchHash: queryJSON.similarSearchHash, - currentSearchKey: heldSearchKey, - currentSearchQueryJSON: queryJSON, - } - : liveQueryValue; - - const resultsValue: SearchResultsContextValue = isRenderingHeldSearch - ? { - ...liveResultsValue, - currentSearchResults: searchResults, - currentSearchTransactionsByReportID: heldData ? getTransactionsByReportID(heldData) : EMPTY_TRANSACTIONS_BY_REPORT_ID, - currentSearchViolations: heldData ? getViolationsFromSearchData(heldData) : CONST.EMPTY_OBJECT, - shouldUseLiveData: !!heldSearchKey && isTodoSearch(queryJSON.recentSearchHash, liveQueryValue.suggestedSearches), - } - : liveResultsValue; - - return ( - - {children} - - ); -} - -export default SearchContentContextProvider; diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 3d3e842cb3de..069183d4d97c 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -7,7 +7,6 @@ import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import Search from '@components/Search'; -import SearchContentContextProvider from '@components/Search/SearchContentContextProvider'; import {useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPageHeaderNarrow'; @@ -320,87 +319,81 @@ function SearchPageNarrow({ /> )} - {/* The rendered pair is re-provided to the search contexts so held rows keep the snapshot they belong to. */} - - - {useStaticRendering && ( - <> - {isInteractive && ( - - )} - {shouldRenderLayoutProbe && } - {!!searchOverlayContent && ( - - {searchOverlayContent} - - )} - - )} - {!useStaticRendering && ( - <> - {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - - {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones + + {useStaticRendering && ( + <> + {isInteractive && ( + + )} + {shouldRenderLayoutProbe && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + {!useStaticRendering && ( + <> + {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} + + {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones stay on screen until then, and the new ones fade in. Absolutely filled so it never shares the parent's column layout with the layer it replaces. */} - - {shouldRenderLoadingSkeleton && ( - - )} - {!shouldShowLoadingSkeleton && ( - - )} - - - {shouldRenderLayoutProbe && } - {!!searchOverlayContent && ( - - {searchOverlayContent} - - )} - - )} - - - + + {shouldRenderLoadingSkeleton && ( + + )} + {!shouldShowLoadingSkeleton && ( + + )} + + + {shouldRenderLayoutProbe && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + + {(!useStaticRendering || isHeaderInteractive) && ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index b6b07862ecea..861b87df8fa6 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -3,7 +3,6 @@ import {useSearchSidebarContentOffsetStyle} from '@components/Navigation/SearchS import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; -import SearchContentContextProvider from '@components/Search/SearchContentContextProvider'; import {useSearchQueryContext, useSearchSelectionContext} from '@components/Search/SearchContext'; import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchActionsBarWide from '@components/Search/SearchPageHeader/SearchActionsBarWide'; @@ -137,42 +136,36 @@ function SearchPageWide({ searchResults={searchResults} onSort={onSortPressedCallback} /> - {/* The rendered pair is re-provided to the search contexts so held rows keep the snapshot they belong to. */} - - - {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - - {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones + + {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} + + {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones stay on screen until then, and the new ones fade in. Absolutely filled so it never shares the parent's column layout with the layer it replaces. */} - - {shouldRenderLoadingSkeleton && } - {!shouldShowLoadingSkeleton && ( - - )} - - - {!!searchOverlayContent && {searchOverlayContent}} - - - + + {shouldRenderLoadingSkeleton && } + {!shouldShowLoadingSkeleton && ( + + )} + + + {!!searchOverlayContent && {searchOverlayContent}} + + )} From 1b2d3ff4e65252369b935af16d17c74edb08fe9d Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Thu, 3 Sep 2026 21:48:21 +0000 Subject: [PATCH 125/485] Fade the Spend skeleton out with reanimated exiting The custom opacity fade existed because exiting on web fades an element by detaching its DOM node, which threw NotFoundError from commitDeletionEffectsOnFiber. That came from the wrapper around the results list, which tears the whole list down mid-animation. The skeleton kept its own exiting through that fix and neither symptom reproduces with it, so it goes back to the entering/exiting pair it has on main. --- .../Search/SearchLoadingSkeleton.tsx | 33 ++++++------------- src/hooks/useSearchSkeletonVisibility.ts | 33 ------------------- src/pages/Search/SearchPageNarrow/index.tsx | 12 ++----- src/pages/Search/SearchPageWide.tsx | 7 ++-- 4 files changed, 16 insertions(+), 69 deletions(-) delete mode 100644 src/hooks/useSearchSkeletonVisibility.ts diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index c3f984f5f317..77908ab0607c 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -1,5 +1,7 @@ import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; +import useThemeStyles from '@hooks/useThemeStyles'; + import {endSpanWithAttributes} from '@libs/telemetry/activeSpans'; import {endNavigateToReportsFirstPaint} from '@libs/telemetry/navigateToReportsSpans'; @@ -7,43 +9,28 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useEffect} from 'react'; -import {StyleSheet} from 'react-native'; -import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import React from 'react'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; type SearchLoadingSkeletonProps = { - /** Whether the query still has no results. Turning false fades this out over the results behind it. */ - isLoading: boolean; - containerStyle?: StyleProp; }; -function SearchLoadingSkeleton({isLoading, containerStyle}: SearchLoadingSkeletonProps) { - const opacity = useSharedValue(1); - const animatedStyle = useAnimatedStyle(() => ({opacity: opacity.get()})); - - useEffect(() => { - if (isLoading) { - opacity.set(1); - return; - } - - opacity.set(withTiming(0, {duration: CONST.SEARCH.ANIMATION.FADE_DURATION})); - }, [isLoading, opacity]); +function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { + const styles = useThemeStyles(); return ( - // Absolutely filled so it never shares the parent's column layout with the incoming results, which would - // halve both heights for a frame. Not hit-testable, so the results underneath stay usable as it fades. { endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.COLD); }} > diff --git a/src/hooks/useSearchSkeletonVisibility.ts b/src/hooks/useSearchSkeletonVisibility.ts deleted file mode 100644 index 29270074349a..000000000000 --- a/src/hooks/useSearchSkeletonVisibility.ts +++ /dev/null @@ -1,33 +0,0 @@ -import CONST from '@src/CONST'; - -import {useEffect, useState} from 'react'; - -/** - * Keeps the search skeleton rendered for one fade after its query resolves, so it can animate out over the results - * that replaced it. - * - * Reanimated's `exiting` would do this too, but on web it fades an element by detaching its DOM node, and this - * skeleton is the one on screen during a cold start — navigating away mid-animation left React removing a node that - * had moved, throwing NotFoundError. - */ -function useSearchSkeletonVisibility(isLoading: boolean) { - const [shouldRender, setShouldRender] = useState(isLoading); - - // Adjusted during rendering because the value is consumed in the same render; the guard bounds it to one pass. - if (isLoading && !shouldRender) { - setShouldRender(true); - } - - useEffect(() => { - if (isLoading || !shouldRender) { - return; - } - - const timeoutID = setTimeout(() => setShouldRender(false), CONST.SEARCH.ANIMATION.FADE_DURATION); - return () => clearTimeout(timeoutID); - }, [isLoading, shouldRender]); - - return shouldRender; -} - -export default useSearchSkeletonVisibility; diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 069183d4d97c..24087a7b0d2b 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -22,7 +22,6 @@ import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSearchLoadingState from '@hooks/useSearchLoadingState'; -import useSearchSkeletonVisibility from '@hooks/useSearchSkeletonVisibility'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -89,7 +88,6 @@ function SearchPageNarrow({ isOverlayActive, }: SearchPageNarrowProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(contentQueryJSON, contentSearchResults); - const shouldRenderLoadingSkeleton = useSearchSkeletonVisibility(shouldShowLoadingSkeleton); // A layer replacing results already on screen renders its hydrate placeholder invisibly, since a skeleton there // reads as a flash between two sets of results. @@ -359,13 +357,9 @@ function SearchPageNarrow({ entering={FadeIn.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)} style={StyleSheet.absoluteFill} > - {shouldRenderLoadingSkeleton && ( - - )} - {!shouldShowLoadingSkeleton && ( + {shouldShowLoadingSkeleton ? ( + + ) : ( - {shouldRenderLoadingSkeleton && } - {!shouldShowLoadingSkeleton && ( + {shouldShowLoadingSkeleton ? ( + + ) : ( Date: Thu, 3 Sep 2026 21:57:41 +0000 Subject: [PATCH 126/485] Trim the Spend fade comments to two lines each --- .../SearchWithNavigationDeferredMount.tsx | 6 ++--- src/libs/SearchNavigationUtils.ts | 14 ++++-------- src/libs/SearchQueryRefinement.ts | 11 ++-------- src/pages/Search/SearchPage.tsx | 22 ++++++------------- src/pages/Search/SearchPageNarrow/index.tsx | 5 ++--- src/pages/Search/SearchPageWide.tsx | 5 ++--- 6 files changed, 19 insertions(+), 44 deletions(-) diff --git a/src/components/Search/SearchWithNavigationDeferredMount.tsx b/src/components/Search/SearchWithNavigationDeferredMount.tsx index 9c5656a1b48c..4fe16b95fa14 100644 --- a/src/components/Search/SearchWithNavigationDeferredMount.tsx +++ b/src/components/Search/SearchWithNavigationDeferredMount.tsx @@ -48,10 +48,8 @@ function SearchWithNavigationDeferredMount({isReplacingContent = false, ...props (undefined); - // Changing a filter builds a query that has never been cached, so keying the results area on the requested - // query would mount it with no data and flash a skeleton in the middle of the fade. Key it on the last query - // that actually resolved instead: the current results stay on screen until the new ones arrive, then the area - // swaps once. Adjusted during rendering for the same reason as lastNonEmptySearchResults above — the values - // below are consumed in this render, and the reference check bounds the loop to one extra pass. - // isCurrentSearchResolved, not a raw hash comparison: a response folds sort defaults into its own hash, so the - // requested and returned hashes legitimately differ and isSearchDataLoaded is what reconciles them. + // Keying the results area on the requested query would mount it with no data, since a filter builds a query that + // has never been cached. isCurrentSearchResolved, not a hash comparison: a response folds sort defaults into its own hash. const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); } - // A slow query would otherwise leave the previous results up indefinitely with nothing to show a wait is happening - // (the wide layout has no loading bar). Keyed by hash rather than reset on resolve so the effect never has to call - // setState synchronously; a hash that no longer matches simply stops counting. + // A slow query would otherwise hold the previous results up indefinitely, and the wide layout has no loading bar. + // Keyed by hash rather than reset on resolve, so the effect below never has to call setState synchronously. const [staleHoldTimedOutHash, setStaleHoldTimedOutHash] = useState(undefined); const currentQueryHash = currentSearchQueryJSON?.hash; @@ -150,9 +143,8 @@ function SearchPage({route}: SearchPageProps) { return () => clearTimeout(timeoutID); }, [isSearchResolvedForCurrentQuery, currentQueryHash]); - // Cleared once the query resolves, so returning to it later starts a fresh count. Leaving the hash marked would - // skip the hold for good — a filter toggled off and back on, or a snapshot evicted, would go straight to the - // skeleton. Adjusted during rendering rather than in the effect above, which must not call setState synchronously. + // Leaving the hash marked would skip the hold for good, so a filter toggled off and back on would go straight to + // the skeleton. Adjusted during rendering rather than in the effect above, which must not call setState synchronously. if (isSearchResolvedForCurrentQuery && staleHoldTimedOutHash === currentQueryHash) { setStaleHoldTimedOutHash(undefined); } diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 24087a7b0d2b..d3df27d33c91 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -349,9 +349,8 @@ function SearchPageNarrow({ <> {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones - stay on screen until then, and the new ones fade in. Absolutely filled so it never shares the - parent's column layout with the layer it replaces. */} + {/* Keyed on the resolved query, so this only remounts once the new results arrive. Absolutely + filled so it never shares the parent's column layout with the layer it replaces. */} {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} - {/* Keyed on the resolved query, so this only remounts once the new results arrive — the previous ones - stay on screen until then, and the new ones fade in. Absolutely filled so it never shares the - parent's column layout with the layer it replaces. */} + {/* Keyed on the resolved query, so this only remounts once the new results arrive. Absolutely + filled so it never shares the parent's column layout with the layer it replaces. */} Date: Thu, 3 Sep 2026 22:36:58 +0000 Subject: [PATCH 127/485] Navigate to Spend searches instead of pushing params The page does not remount on a sidebar or saved-search press, so the focused route no longer has to be updated in place to keep the fade. --- src/libs/SearchNavigationUtils.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index 146c36cfd8ec..30be02d27f16 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -1,29 +1,14 @@ -import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import {setSearchContext} from './actions/Search'; import Navigation from './Navigation/Navigation'; -import navigationRef from './Navigation/navigationRef'; import {clearQueryRefinement} from './SearchQueryRefinement'; -/** - * `Navigation.navigate` would push another SEARCH.ROOT route and remount SearchPage, losing anything it was animating, - * so update the focused route instead. Only valid when SEARCH.ROOT is focused, since the sidebar also shows over reports. - */ function navigateToSpendSearch(query: string, name?: string) { // Opening a search is never a refinement, so a query the filters built earlier must not stay marked and be held // later when the same query string arrives from the sidebar or a saved search. clearQueryRefinement(); - if (navigationRef.isReady() && navigationRef.getCurrentRoute()?.name === SCREENS.SEARCH.ROOT) { - navigationRef.dispatch({ - type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS, - payload: {params: {q: query, name, rawQuery: undefined}}, - }); - return; - } - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query, name})); } From c5636e82e9a17236785b71e257c743ab1ef7140f Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Thu, 3 Sep 2026 22:48:56 +0000 Subject: [PATCH 128/485] Identify a different Spend search without a refinement marker A sidebar item is one of the suggested searches, so it resolves a currentSearchKey, and a saved search carries a name. A filter refinement matches neither, so the hold no longer needs a query to be marked as one. Removes SearchQueryRefinement and both of its clearing call sites. --- .../Search/hooks/useUpdateFilterQuery.tsx | 2 -- src/libs/SearchNavigationUtils.ts | 12 +---------- src/libs/SearchQueryRefinement.ts | 21 ------------------- src/pages/Search/SavedSearchList.tsx | 5 +++-- src/pages/Search/SearchPage.tsx | 9 ++++---- 5 files changed, 9 insertions(+), 40 deletions(-) delete mode 100644 src/libs/SearchQueryRefinement.ts diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 2c93d9e5bc74..1a1e171dc90d 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -4,7 +4,6 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; -import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; @@ -50,7 +49,6 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { return; } - markQueryAsRefinement(queryString); Navigation.setParams({q: queryString, rawQuery: undefined}); } diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index 30be02d27f16..925faf6c3590 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -2,21 +2,11 @@ import ROUTES from '@src/ROUTES'; import {setSearchContext} from './actions/Search'; import Navigation from './Navigation/Navigation'; -import {clearQueryRefinement} from './SearchQueryRefinement'; - -function navigateToSpendSearch(query: string, name?: string) { - // Opening a search is never a refinement, so a query the filters built earlier must not stay marked and be held - // later when the same query string arrives from the sidebar or a saved search. - clearQueryRefinement(); - - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query, name})); -} function navigateToCannedSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { clearSelectedTransactions(); setSearchContext(false); - navigateToSpendSearch(searchQuery); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); } export default navigateToCannedSpendSearch; -export {navigateToSpendSearch}; diff --git a/src/libs/SearchQueryRefinement.ts b/src/libs/SearchQueryRefinement.ts deleted file mode 100644 index 0b67ae667b11..000000000000 --- a/src/libs/SearchQueryRefinement.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type {SearchQueryString} from '@components/Search/types'; - -/** - * Only a filter refinement may hold the previous results, and a query alone can't prove one — a sidebar item and a - * filter tweak both arrive as a new `q`. An unknown query counts as a different search, so a missed mark costs a skeleton. - */ -let refinedQuery: SearchQueryString | undefined; - -function markQueryAsRefinement(query: SearchQueryString) { - refinedQuery = query; -} - -function clearQueryRefinement() { - refinedQuery = undefined; -} - -function isQueryARefinement(query: SearchQueryString | undefined) { - return !!query && refinedQuery === query; -} - -export {markQueryAsRefinement, clearQueryRefinement, isQueryARefinement}; diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index b6af7a0ff33e..a3c5e060fb56 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -14,8 +14,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {navigateToSpendSearch} from '@libs/SearchNavigationUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil, SAVED_SEARCH_FALLBACK_ICON_NAME, SAVED_SEARCH_ICON_NAMES} from '@libs/SearchUIUtils'; @@ -23,6 +23,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -61,7 +62,7 @@ function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMen sentryLabel: CONST.SENTRY_LABEL.SEARCH.SAVED_SEARCH_MENU_ITEM, onPress: () => { setSearchContext(false); - navigateToSpendSearch(item?.query ?? '', item?.name); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); }, rightComponent: ( Date: Thu, 3 Sep 2026 23:02:42 +0000 Subject: [PATCH 129/485] Describe what the isSorting reset guards against The comment attributed the reset to a page remount that no longer appears in the code, and the filter path never remounted anyway. --- src/pages/Search/SearchPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7a4b6f6cdae1..4543f012bb1f 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -75,8 +75,8 @@ function SearchPage({route}: SearchPageProps) { const [isSorting, setIsSorting] = useState(false); - // Opening a search no longer remounts this page, so a flag left set would render the previous query's rows under - // the new query. Adjusted during rendering because searchResults below consumes it in this same render. + // Sorting keeps the previous results on screen while the re-sorted ones load, so changing the search has to cancel + // it or those rows render under the new query. Adjusted during rendering because searchResults consumes it below. const previousQueryHash = usePrevious(currentSearchQueryJSON?.hash); if (isSorting && previousQueryHash !== currentSearchQueryJSON?.hash) { setIsSorting(false); From a7f5227e871d7d4cf6b2be787ea7e10137e89a49 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:44:40 +0530 Subject: [PATCH 130/485] Re-fetch active tag search query on reconnect --- src/hooks/useSearchTagFilters.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index e76ba081db3b..a2b28c3c2230 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -125,8 +125,9 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { }); }; - // Fetch the first page on mount and when the workspace scope changes. + // Fetch the first page on mount, when the workspace scope changes, and on reconnect. // Skips the fetch while offline and re-fetches on reconnect, matching useLoadSearchCategoryData. + // Reconnect uses the active search query so results stay in sync with the search input. // searchTags is not memoized, so it cannot be in the dependency array — it would fire on every render. // searchTags reads latest state via refs, so the closure captured here is safe to call. useEffect(() => { @@ -134,7 +135,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { return; } // Defer to a microtask so setState calls inside searchTags don't fire synchronously within the effect body - Promise.resolve().then(() => searchTags('')); + Promise.resolve().then(() => searchTags(stateRef.current.searchQuery)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [policyIDs, isOffline]); From 5478b285cc521b06875850c6e705e06c0b66c94c Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 4 Sep 2026 15:02:44 +0700 Subject: [PATCH 131/485] fix: conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 8b03dfa66cb7..1c134819a4ab 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 8b03dfa66cb71d2c113310b0691c7e8263e22766 +Subproject commit 1c134819a4ab3a5485d6a02e554ce50203c50a9f From 4180293dffb528253a79ce5507a945c0088276a5 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 4 Sep 2026 15:06:28 +0700 Subject: [PATCH 132/485] revert: restore unmodified GitHub action bundles --- .../isDeployChecklistLocked/index.js | 255 +++++++++++++++++- .../javascript/proposalPoliceComment/index.js | 255 +++++++++++++++++- 2 files changed, 500 insertions(+), 10 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index f0d47fc82f50..d228861485f1 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -3431,7 +3431,9 @@ var require_CONST = __commonJS({ REGISTER_AUTHENTICATION_KEY: "register_authentication_key", REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", - REPORT_CARD_FRAUD: "report_card_fraud" + REPORT_CARD_FRAUD: "report_card_fraud", + ISSUE_CARD: "issue_card", + UPDATE_CARD: "update_card" }, EXPENSIFY_CARD: { FEED_NAME: "Expensify Card", @@ -23108,9 +23110,22 @@ var require_ExpensiMark = __commonJS({ var Logger_1 = __importDefault(require_Logger()); var Utils = __importStar(require_utils()); var EXTRAS_DEFAULT = {}; + var ASCII_DIGIT_START = "0".charCodeAt(0); + var ASCII_DIGIT_END = "9".charCodeAt(0); + var ASCII_UPPERCASE_START = "A".charCodeAt(0); + var ASCII_UPPERCASE_END = "Z".charCodeAt(0); + var ASCII_LOWERCASE_START = "a".charCodeAt(0); + var ASCII_LOWERCASE_END = "z".charCodeAt(0); + var ASCII_WHITESPACE_END = " ".charCodeAt(0); + var NON_BREAKING_SPACE_CODE = 160; + var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; + var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join("|")}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); + var BOLD_MARKDOWN_REGEX = /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; + var STRIKETHROUGH_MARKDOWN_REGEX = /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; var SLACK_SPAN_NEW_LINE_TAG = ''; var VICTORY_CHART_REGEX = /]*\/>|]*>[\s\S]*?<\/VictoryChart>/gi; var VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0); @@ -23150,6 +23165,218 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function isAsciiAlphaNumeric(character) { + if (!character) { + return false; + } + const code = character.charCodeAt(0); + return code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END || code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END || code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END; + } + function isWordCharacter(character) { + return character === "_" || isAsciiAlphaNumeric(character); + } + function canOpenBoldMarkdown(text, position, isProtected) { + if (isProtected) { + return false; + } + const nextCharacter = text[position + 1]; + if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith("", tagStart + 1); + if (tagEnd === -1) { + return void 0; + } + const tag = text.slice(tagStart + 1, tagEnd).trim(); + const isClosingTag = tag.startsWith("/"); + const tagName = (_b = (_a = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + if (tagName && PROTECTED_TAG_NAMES.has(tagName)) { + if (isClosingTag) { + const matchingTagIndex = protectedTags.lastIndexOf(tagName); + if (matchingTagIndex !== -1) { + protectedTags.splice(matchingTagIndex, 1); + } + } else if (!tag.endsWith("/")) { + protectedTags.push(tagName); + } + } + return tagEnd + 1; + } + function isHostnameCharacter(character) { + return !!character && (isAsciiAlphaNumeric(character) || character === "-" || character === "."); + } + function isUrlBoundarySpace(character) { + const code = character.charCodeAt(0); + return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE; + } + function getProtocolAt(text, position) { + var _a; + const firstCharacter = (_a = text[position]) === null || _a === void 0 ? void 0 : _a.toLowerCase(); + if (firstCharacter !== "h" && firstCharacter !== "f") { + return void 0; + } + return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); + } + function findHostnameEnd(text, hostnameStart, dotPosition) { + let hostnameEnd = dotPosition + 1; + while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { + hostnameEnd++; + } + if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { + return void 0; + } + return hostnameEnd; + } + function extendUrlCandidateBoundaries(text, start, end) { + let candidateStart = start; + while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) { + candidateStart--; + } + let candidateEnd = end; + while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== "<") { + candidateEnd++; + } + return { start: candidateStart, end: candidateEnd }; + } + function findUrlCandidates(text) { + const candidates = []; + const protectedTags = []; + let index = 0; + let hostnameRunStart = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + hostnameRunStart = index; + continue; + } + if (protectedTags.length > 0) { + index++; + hostnameRunStart = index; + continue; + } + const matchedProtocol = getProtocolAt(text, index); + if (matchedProtocol) { + const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + candidates.push(candidate2); + index = candidate2.end; + hostnameRunStart = candidate2.end; + continue; + } + if (!isHostnameCharacter(text[index])) { + hostnameRunStart = index + 1; + index++; + continue; + } + if (text[index] !== ".") { + index++; + continue; + } + const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); + if (hostnameEnd === void 0) { + index++; + continue; + } + const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + candidates.push(candidate); + index = candidate.end; + hostnameRunStart = candidate.end; + } + return candidates; + } + function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { + if (!text.includes(marker)) { + return text; + } + const markers = []; + const protectedTags = []; + let index = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + continue; + } + if (text[index] === marker) { + markers.push({ position: index, isProtected: protectedTags.length > 0 }); + } + index++; + } + if (markers.length < 2) { + return text; + } + const output = []; + const candidateRegex = regexp; + let outputStart = 0; + let openingMarker; + for (const currentMarker of markers) { + const markerPosition = currentMarker.position; + if (openingMarker === void 0) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) { + continue; + } + if (openingMarker.isProtected) { + openingMarker = void 0; + continue; + } + const openingPosition = openingMarker.position; + const prefixLength = openingPosition > 0 ? 1 : 0; + const suffixLength = markerPosition + 1 < text.length ? 1 : 0; + const candidateStart = openingPosition - prefixLength; + const candidateEnd = markerPosition + 1 + suffixLength; + const candidate = text.slice(candidateStart, candidateEnd); + candidateRegex.lastIndex = 0; + const candidateMatch = candidateRegex.exec(candidate); + if (!candidateMatch) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + candidateRegex.lastIndex = 0; + const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement); + if (replacedCandidate !== candidate) { + const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length; + output.push(text.slice(outputStart, openingPosition)); + output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd)); + outputStart = markerPosition + 1; + openingMarker = void 0; + continue; + } + openingMarker = void 0; + } + if (output.length === 0) { + return text; + } + output.push(text.slice(outputStart)); + return output.join(""); + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -23570,7 +23797,7 @@ var require_ExpensiMark = __commonJS({ name: "autolink", process: (textToProcess, replacement) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -23675,7 +23902,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - regex: /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), replacement: (_extras, match, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -23685,7 +23912,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match, g1) => g1.includes("") || containsNonPairTag(g1) ? match : `${g1}` }, { @@ -24075,7 +24302,25 @@ ${g2} /** * Checks matched URLs for validity and replace valid links with html elements */ - modifyTextForUrlLinks(regex2, textToCheck, replacement) { + modifyTextForUrlLinks(regex2, textToCheck, replacement, shouldScanForUrls = false) { + if (shouldScanForUrls) { + const candidates = findUrlCandidates(textToCheck); + if (candidates.length === 0) { + return textToCheck; + } + const output = []; + const candidateRegex = regex2; + let outputStart = 0; + for (const { start, end } of candidates) { + const candidate = textToCheck.slice(start, end); + candidateRegex.lastIndex = 0; + output.push(textToCheck.slice(outputStart, start)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + outputStart = end; + } + output.push(textToCheck.slice(outputStart)); + return output.join(""); + } let match = regex2.exec(textToCheck); let replacedText = ""; let startIndex = 0; diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 3fa5c1e69771..8310177805a9 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -24245,7 +24245,9 @@ var require_CONST = __commonJS({ REGISTER_AUTHENTICATION_KEY: "register_authentication_key", REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", - REPORT_CARD_FRAUD: "report_card_fraud" + REPORT_CARD_FRAUD: "report_card_fraud", + ISSUE_CARD: "issue_card", + UPDATE_CARD: "update_card" }, EXPENSIFY_CARD: { FEED_NAME: "Expensify Card", @@ -43922,9 +43924,22 @@ var require_ExpensiMark = __commonJS({ var Logger_1 = __importDefault(require_Logger()); var Utils = __importStar(require_utils2()); var EXTRAS_DEFAULT = {}; + var ASCII_DIGIT_START = "0".charCodeAt(0); + var ASCII_DIGIT_END = "9".charCodeAt(0); + var ASCII_UPPERCASE_START = "A".charCodeAt(0); + var ASCII_UPPERCASE_END = "Z".charCodeAt(0); + var ASCII_LOWERCASE_START = "a".charCodeAt(0); + var ASCII_LOWERCASE_END = "z".charCodeAt(0); + var ASCII_WHITESPACE_END = " ".charCodeAt(0); + var NON_BREAKING_SPACE_CODE = 160; + var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; + var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join("|")}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); + var BOLD_MARKDOWN_REGEX = /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; + var STRIKETHROUGH_MARKDOWN_REGEX = /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; var SLACK_SPAN_NEW_LINE_TAG = ''; var VICTORY_CHART_REGEX = /]*\/>|]*>[\s\S]*?<\/VictoryChart>/gi; var VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0); @@ -43964,6 +43979,218 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function isAsciiAlphaNumeric(character) { + if (!character) { + return false; + } + const code = character.charCodeAt(0); + return code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END || code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END || code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END; + } + function isWordCharacter(character) { + return character === "_" || isAsciiAlphaNumeric(character); + } + function canOpenBoldMarkdown(text, position, isProtected) { + if (isProtected) { + return false; + } + const nextCharacter = text[position + 1]; + if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith("", tagStart + 1); + if (tagEnd === -1) { + return void 0; + } + const tag = text.slice(tagStart + 1, tagEnd).trim(); + const isClosingTag = tag.startsWith("/"); + const tagName = (_b = (_a3 = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a3 === void 0 ? void 0 : _a3[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + if (tagName && PROTECTED_TAG_NAMES.has(tagName)) { + if (isClosingTag) { + const matchingTagIndex = protectedTags.lastIndexOf(tagName); + if (matchingTagIndex !== -1) { + protectedTags.splice(matchingTagIndex, 1); + } + } else if (!tag.endsWith("/")) { + protectedTags.push(tagName); + } + } + return tagEnd + 1; + } + function isHostnameCharacter(character) { + return !!character && (isAsciiAlphaNumeric(character) || character === "-" || character === "."); + } + function isUrlBoundarySpace(character) { + const code = character.charCodeAt(0); + return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE; + } + function getProtocolAt(text, position) { + var _a3; + const firstCharacter = (_a3 = text[position]) === null || _a3 === void 0 ? void 0 : _a3.toLowerCase(); + if (firstCharacter !== "h" && firstCharacter !== "f") { + return void 0; + } + return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); + } + function findHostnameEnd(text, hostnameStart, dotPosition) { + let hostnameEnd = dotPosition + 1; + while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { + hostnameEnd++; + } + if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { + return void 0; + } + return hostnameEnd; + } + function extendUrlCandidateBoundaries(text, start, end) { + let candidateStart = start; + while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) { + candidateStart--; + } + let candidateEnd = end; + while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== "<") { + candidateEnd++; + } + return { start: candidateStart, end: candidateEnd }; + } + function findUrlCandidates(text) { + const candidates = []; + const protectedTags = []; + let index = 0; + let hostnameRunStart = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + hostnameRunStart = index; + continue; + } + if (protectedTags.length > 0) { + index++; + hostnameRunStart = index; + continue; + } + const matchedProtocol = getProtocolAt(text, index); + if (matchedProtocol) { + const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + candidates.push(candidate2); + index = candidate2.end; + hostnameRunStart = candidate2.end; + continue; + } + if (!isHostnameCharacter(text[index])) { + hostnameRunStart = index + 1; + index++; + continue; + } + if (text[index] !== ".") { + index++; + continue; + } + const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); + if (hostnameEnd === void 0) { + index++; + continue; + } + const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + candidates.push(candidate); + index = candidate.end; + hostnameRunStart = candidate.end; + } + return candidates; + } + function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { + if (!text.includes(marker)) { + return text; + } + const markers = []; + const protectedTags = []; + let index = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + continue; + } + if (text[index] === marker) { + markers.push({ position: index, isProtected: protectedTags.length > 0 }); + } + index++; + } + if (markers.length < 2) { + return text; + } + const output = []; + const candidateRegex = regexp; + let outputStart = 0; + let openingMarker; + for (const currentMarker of markers) { + const markerPosition = currentMarker.position; + if (openingMarker === void 0) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) { + continue; + } + if (openingMarker.isProtected) { + openingMarker = void 0; + continue; + } + const openingPosition = openingMarker.position; + const prefixLength = openingPosition > 0 ? 1 : 0; + const suffixLength = markerPosition + 1 < text.length ? 1 : 0; + const candidateStart = openingPosition - prefixLength; + const candidateEnd = markerPosition + 1 + suffixLength; + const candidate = text.slice(candidateStart, candidateEnd); + candidateRegex.lastIndex = 0; + const candidateMatch = candidateRegex.exec(candidate); + if (!candidateMatch) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + candidateRegex.lastIndex = 0; + const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement); + if (replacedCandidate !== candidate) { + const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length; + output.push(text.slice(outputStart, openingPosition)); + output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd)); + outputStart = markerPosition + 1; + openingMarker = void 0; + continue; + } + openingMarker = void 0; + } + if (output.length === 0) { + return text; + } + output.push(text.slice(outputStart)); + return output.join(""); + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -44384,7 +44611,7 @@ var require_ExpensiMark = __commonJS({ name: "autolink", process: (textToProcess, replacement) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -44489,7 +44716,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - regex: /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), replacement: (_extras, match2, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -44499,7 +44726,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match2, g1) => g1.includes("") || containsNonPairTag(g1) ? match2 : `${g1}` }, { @@ -44889,7 +45116,25 @@ ${g2} /** * Checks matched URLs for validity and replace valid links with html elements */ - modifyTextForUrlLinks(regex2, textToCheck, replacement) { + modifyTextForUrlLinks(regex2, textToCheck, replacement, shouldScanForUrls = false) { + if (shouldScanForUrls) { + const candidates = findUrlCandidates(textToCheck); + if (candidates.length === 0) { + return textToCheck; + } + const output = []; + const candidateRegex = regex2; + let outputStart = 0; + for (const { start, end } of candidates) { + const candidate = textToCheck.slice(start, end); + candidateRegex.lastIndex = 0; + output.push(textToCheck.slice(outputStart, start)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + outputStart = end; + } + output.push(textToCheck.slice(outputStart)); + return output.join(""); + } let match2 = regex2.exec(textToCheck); let replacedText = ""; let startIndex = 0; From d01f0d3b76c249de62f332a91bfc125bae02856d Mon Sep 17 00:00:00 2001 From: dominictb Date: Sat, 5 Sep 2026 16:27:34 +0700 Subject: [PATCH 133/485] fix: missing None option in Xero default contact selector --- .../DynamicXeroNonReimbursableDefaultContactSelectPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 63764ebb9d1a..1b30a0737d04 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -167,7 +167,7 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy value: searchText, onChangeText: setSearchText, }} - initiallyFocusedOptionKey={data.find((item) => item.isSelected)?.keyForList} + initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : data.find((item) => item.isSelected)?.keyForList} listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.XERO} onBackButtonPress={goBack} From af3206a27947104f3c140bcad45e580b98a3a966 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:49:50 +0530 Subject: [PATCH 134/485] Center tag search spinner and use local filter when cache complete --- .../Search/FilterComponents/MultiSelect.tsx | 23 ++- .../Search/FilterComponents/TagSelector.tsx | 10 +- src/hooks/useSearchTagFilters.ts | 67 ++++++-- tests/unit/hooks/useSearchTagFiltersTest.ts | 149 ++++++++++++++++++ 4 files changed, 225 insertions(+), 24 deletions(-) create mode 100644 tests/unit/hooks/useSearchTagFiltersTest.ts diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index d9a39c5dece8..d5de9bd576a7 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -65,6 +65,9 @@ type MultiSelectProps = SearchFilterCommonProps>> & /** Whether a search is in progress (suppresses "No results found" while true) */ isLoading?: boolean; + + /** Whether a server search is in progress (shows a centered spinner in the list area) */ + isSearching?: boolean; }; function MultiSelect({ @@ -85,6 +88,7 @@ function MultiSelect({ onSearchChange, isLoadingMore, isLoading, + isSearching, }: MultiSelectProps) { const theme = useTheme(); const {translate} = useLocalize(); @@ -126,7 +130,9 @@ function MultiSelect({ leftElement: item.leftElement, })); - const headerMessage = isSearchable && listData.length === 0 && !isLoading ? translate('common.noResultsFound') : undefined; + const isSearchInProgress = isSearching || isLoading; + const displayListData = isSearching ? [] : listData; + const headerMessage = isSearchable && displayListData.length === 0 && !isSearchInProgress ? translate('common.noResultsFound') : undefined; const updateSelectedItems = (item: ListItem) => { if (item.isSelected) { @@ -172,7 +178,7 @@ function MultiSelect({ return ( ({ shouldSingleExecuteRowSelect shouldUpdateFocusedIndex shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} - data={listData} + data={displayListData} ListItem={MultiSelectListItem} onSelectRow={updateSelectedItems} textInputOptions={textInputOptions} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} footerContent={footerContent} onEndReached={onEndReached} + listEmptyContent={ + isSearching ? ( + + + + ) : undefined + } + shouldShowListEmptyContent={isSearching} /> )} diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 04e96a8f32ad..4e81f513d59f 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -21,7 +21,7 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti const {translate} = useLocalize(); // A negated workspace filter cannot be expressed as an inclusion list, so it falls back to searching every workspace const policyIDs = policyID?.isNegated ? '' : (policyID?.value ?? []).join(','); - const {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading, searchQuery} = useSearchTagFilters(policyIDs); + const {searchResults, isSearching, isLoadingMore, hasMore, loadMore, searchTags, isInitialLoading, searchQuery} = useSearchTagFilters(policyIDs); const tagItems: Array> = []; const emptyTagItem = {text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}; @@ -55,8 +55,8 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti }); // Selected tags that are not in the current result page stay visible once the search is cleared, so the selection doesn't disappear from the list. - // Gated on !isLoading so stale results during a fetch don't cause selected tags to be appended in the wrong order. - if (!searchQuery && !isLoading) { + // Gated on !isSearching so stale results during a fetch don't cause selected tags to be appended in the wrong order. + if (!searchQuery && !isSearching) { const itemValues = new Set(tagItems.map((item) => item.value)); for (const selectedItem of selectedTagsItems) { if (!itemValues.has(selectedItem.value)) { @@ -78,8 +78,8 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti onEndReached={hasMore ? loadMore : undefined} onSearchChange={searchTags} loading={isInitialLoading} - isLoading={isLoading} - isLoadingMore={isLoading} + isSearching={isSearching} + isLoadingMore={isLoadingMore} /> ); } diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index a2b28c3c2230..74b42053d9d5 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -16,8 +16,11 @@ type UseSearchTagFiltersResult = { /** The paginated tag search results from Onyx, keyed by full Onyx key (searchPolicyTags_) */ searchResults: OnyxCollection; - /** Whether a request is currently in flight */ - isLoading: boolean; + /** Whether a new search request is in flight */ + isSearching: boolean; + + /** Whether the next page of results is loading */ + isLoadingMore: boolean; /** Whether there are more pages to load */ hasMore: boolean; @@ -53,7 +56,8 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { const {isOffline} = useNetwork(); const [searchResults] = useOnyx(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS); const [paginationState] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION); - const [isLoading, setIsLoading] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); // Derive pagination state from Onyx const hasMore = paginationState?.hasMore ?? false; @@ -62,12 +66,13 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // Track if we have cached data to avoid showing loading state on remount const hasCachedData = !!searchResults && Object.keys(searchResults).length > 0; + const hasCompleteCache = hasCachedData && !hasMore; // Keep ref updated with latest values for use in stable callbacks - const stateRef = useRef({hasMore, nextCursor, searchQuery, hasCachedData, isLoading}); + const stateRef = useRef({hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteCache, isSearching, isLoadingMore}); useEffect(() => { - stateRef.current = {hasMore, nextCursor, searchQuery, hasCachedData, isLoading}; - }, [hasMore, nextCursor, searchQuery, hasCachedData, isLoading]); + stateRef.current = {hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteCache, isSearching, isLoadingMore}; + }, [hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteCache, isSearching, isLoadingMore]); // Incremented on every new search so a cancelled request doesn't clear the loading state of its successor const requestSeqRef = useRef(0); @@ -76,13 +81,15 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // so the search input keeps focus while the user types, even when the latest results are empty. const [hasCompletedSearch, setHasCompletedSearch] = useState(hasCachedData); + const prevWasOfflineRef = useRef(isOffline); + const loadMore = () => { - const {hasMore: currentHasMore, nextCursor: currentCursor, searchQuery: currentQuery, isLoading: currentIsLoading} = stateRef.current; - if (currentIsLoading || !currentHasMore || isOffline) { + const {hasMore: currentHasMore, nextCursor: currentCursor, searchQuery: currentQuery, isSearching: currentIsSearching, isLoadingMore: currentIsLoadingMore} = stateRef.current; + if (currentIsSearching || currentIsLoadingMore || !currentHasMore || isOffline) { return; } const requestSeq = requestSeqRef.current; - setIsLoading(true); + setIsLoadingMore(true); openSearchTagFiltersPage({searchQuery: currentQuery, cursor: currentCursor, limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE, policyIDs}) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { setSearchTagFiltersPagination(newHasMore, newCursor, currentQuery); @@ -92,7 +99,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { if (requestSeq !== requestSeqRef.current) { return; } - setIsLoading(false); + setIsLoadingMore(false); }); }; @@ -102,6 +109,16 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { setSearchTagFiltersPagination(false, '', query); return; } + + const {hasCompleteCache: currentHasCompleteCache, hasMore: currentHasMore, nextCursor: currentCursor} = stateRef.current; + + // When the full dataset is already cached, filter locally instead of hitting the server on every keystroke. + if (currentHasCompleteCache) { + setSearchTagFiltersPagination(currentHasMore, currentCursor, query); + setHasCompletedSearch(true); + return; + } + const requestSeq = ++requestSeqRef.current; // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor @@ -109,7 +126,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // A new search cancels any in-flight request, so it owns the loading state from here on. // The collection is cleared by openSearchTagFiltersPage, so there is no cached data to show silently. - setIsLoading(true); + setIsSearching(true); openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE, policyIDs}, true) .then(({hasMore: newHasMore, nextCursor: newCursor}) => { @@ -121,10 +138,17 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { return; } setHasCompletedSearch(true); - setIsLoading(false); + setIsSearching(false); }); }; + // Clear persisted pagination when the filter closes so a fresh open does not reuse a stale query. + useEffect(() => { + return () => { + setSearchTagFiltersPagination(false, '', ''); + }; + }, []); + // Fetch the first page on mount, when the workspace scope changes, and on reconnect. // Skips the fetch while offline and re-fetches on reconnect, matching useLoadSearchCategoryData. // Reconnect uses the active search query so results stay in sync with the search input. @@ -132,16 +156,27 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // searchTags reads latest state via refs, so the closure captured here is safe to call. useEffect(() => { if (isOffline) { + prevWasOfflineRef.current = true; return; } - // Defer to a microtask so setState calls inside searchTags don't fire synchronously within the effect body - Promise.resolve().then(() => searchTags(stateRef.current.searchQuery)); + + const wasOffline = prevWasOfflineRef.current; + prevWasOfflineRef.current = false; + + Promise.resolve().then(() => { + if (wasOffline) { + searchTags(stateRef.current.searchQuery); + return; + } + + searchTags(''); + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [policyIDs, isOffline]); - const isInitialLoading = isLoading && !hasCompletedSearch; + const isInitialLoading = isSearching && !hasCompletedSearch; - return {searchResults, isLoading, hasMore, loadMore, searchTags, isInitialLoading, searchQuery}; + return {searchResults, isSearching, isLoadingMore, hasMore, loadMore, searchTags, isInitialLoading, searchQuery}; } export default useSearchTagFilters; diff --git a/tests/unit/hooks/useSearchTagFiltersTest.ts b/tests/unit/hooks/useSearchTagFiltersTest.ts new file mode 100644 index 000000000000..d8ad946086de --- /dev/null +++ b/tests/unit/hooks/useSearchTagFiltersTest.ts @@ -0,0 +1,149 @@ +import {act, renderHook, waitFor} from '@testing-library/react-native'; + +import useSearchTagFilters from '@hooks/useSearchTagFilters'; + +import {openSearchTagFiltersPage, setSearchTagFiltersPagination} from '@libs/actions/Search'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const mockOpenSearchTagFiltersPage = jest.mocked(openSearchTagFiltersPage); +const mockSetSearchTagFiltersPagination = jest.mocked(setSearchTagFiltersPagination); + +const onyxData: Record = {}; + +let mockIsOffline = false; + +const mockUseOnyx = jest.fn((key: string) => [onyxData[key]]); +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: string) => mockUseOnyx(key), +})); + +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: mockIsOffline}), +})); + +jest.mock('@libs/actions/Search', () => ({ + openSearchTagFiltersPage: jest.fn(() => Promise.resolve({hasMore: false, nextCursor: ''})), + setSearchTagFiltersPagination: jest.fn(), +})); + +jest.mock('@libs/Log', () => ({ + warn: jest.fn(), +})); + +const POLICY_ID = 'policy-1'; + +function setPartialTagFilterState(searchQuery: string) { + onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION] = { + hasMore: true, + nextCursor: 'cursor-1', + searchQuery, + }; + onyxData[ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS] = { + [`${ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS}${POLICY_ID}`]: { + tag1: {tagName: `${searchQuery}-match`}, + }, + }; +} + +function setCompleteTagFilterState(searchQuery: string) { + onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION] = { + hasMore: false, + nextCursor: '', + searchQuery, + }; + onyxData[ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS] = { + [`${ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS}${POLICY_ID}`]: { + tag1: {tagName: `${searchQuery}-match`}, + tag2: {tagName: 'other-tag'}, + }, + }; +} + +describe('useSearchTagFilters', () => { + beforeEach(() => { + for (const key of Object.keys(onyxData)) { + delete onyxData[key]; + } + mockIsOffline = false; + mockUseOnyx.mockClear(); + mockOpenSearchTagFiltersPage.mockClear().mockResolvedValue({hasMore: false, nextCursor: ''}); + mockSetSearchTagFiltersPagination.mockClear(); + }); + + it('fetches with an empty query on mount when pagination still holds a previous search term', async () => { + setPartialTagFilterState('marketing'); + + renderHook(() => useSearchTagFilters(POLICY_ID)); + + await waitFor(() => { + expect(mockOpenSearchTagFiltersPage).toHaveBeenCalledWith( + expect.objectContaining({searchQuery: '', policyIDs: POLICY_ID, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}), + true, + ); + }); + + expect(mockOpenSearchTagFiltersPage).not.toHaveBeenCalledWith(expect.objectContaining({searchQuery: 'marketing'}), expect.anything()); + }); + + it('clears persisted pagination when the filter unmounts', () => { + const {unmount} = renderHook(() => useSearchTagFilters(POLICY_ID)); + + unmount(); + + expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', ''); + }); + + it('re-fetches with the active search query on reconnect while the filter stays mounted', async () => { + setPartialTagFilterState('travel'); + mockIsOffline = true; + + const {rerender} = renderHook(() => useSearchTagFilters(POLICY_ID)); + + await act(async () => { + await Promise.resolve(); + }); + expect(mockOpenSearchTagFiltersPage).not.toHaveBeenCalled(); + + mockIsOffline = false; + rerender({}); + + await waitFor(() => { + expect(mockOpenSearchTagFiltersPage).toHaveBeenCalledWith(expect.objectContaining({searchQuery: 'travel', policyIDs: POLICY_ID}), true); + }); + }); + + it('does not call the API when searching with a complete cached dataset', async () => { + setCompleteTagFilterState(''); + + const {result} = renderHook(() => useSearchTagFilters(POLICY_ID)); + + await act(async () => { + await Promise.resolve(); + }); + mockOpenSearchTagFiltersPage.mockClear(); + + act(() => { + result.current.searchTags('marketing'); + }); + + expect(mockOpenSearchTagFiltersPage).not.toHaveBeenCalled(); + expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', 'marketing'); + }); + + it('resets the search query on mount without refetching when the full dataset is already cached', async () => { + setCompleteTagFilterState('marketing'); + + renderHook(() => useSearchTagFilters(POLICY_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockOpenSearchTagFiltersPage).not.toHaveBeenCalled(); + expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', ''); + }); +}); From 9b3d8f0af77e97c728a50090b70f34c57fac35aa Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:04:02 +0530 Subject: [PATCH 135/485] fix(search): tag filter pagination and clear search Clear tag cache on filter close so remount refetches with valid hasMore. Only skip API when empty-query dataset is fully cached, not after a finished server search for a non-empty term. Keep tag list mounted during search so input keeps focus. --- .../Search/FilterComponents/MultiSelect.tsx | 12 +-- src/hooks/useSearchTagFilters.ts | 22 +++-- src/libs/actions/Search.ts | 7 ++ tests/unit/hooks/useSearchTagFiltersTest.ts | 97 +++++++++++++++++-- 4 files changed, 116 insertions(+), 22 deletions(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index d5de9bd576a7..2da421370e54 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -131,8 +131,8 @@ function MultiSelect({ })); const isSearchInProgress = isSearching || isLoading; - const displayListData = isSearching ? [] : listData; - const headerMessage = isSearchable && displayListData.length === 0 && !isSearchInProgress ? translate('common.noResultsFound') : undefined; + const shouldShowSearchSpinner = isSearching && listData.length === 0; + const headerMessage = isSearchable && listData.length === 0 && !isSearchInProgress ? translate('common.noResultsFound') : undefined; const updateSelectedItems = (item: ListItem) => { if (item.isSelected) { @@ -178,7 +178,7 @@ function MultiSelect({ return ( ({ shouldSingleExecuteRowSelect shouldUpdateFocusedIndex shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} - data={displayListData} + data={listData} ListItem={MultiSelectListItem} onSelectRow={updateSelectedItems} textInputOptions={textInputOptions} @@ -203,7 +203,7 @@ function MultiSelect({ footerContent={footerContent} onEndReached={onEndReached} listEmptyContent={ - isSearching ? ( + shouldShowSearchSpinner ? ( ({ ) : undefined } - shouldShowListEmptyContent={isSearching} + shouldShowListEmptyContent={shouldShowSearchSpinner} /> )} diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 74b42053d9d5..5e03ba253143 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -1,4 +1,4 @@ -import {openSearchTagFiltersPage, setSearchTagFiltersPagination} from '@libs/actions/Search'; +import {clearSearchTagFiltersState, openSearchTagFiltersPage, setSearchTagFiltersPagination} from '@libs/actions/Search'; import Log from '@libs/Log'; import CONST from '@src/CONST'; @@ -66,13 +66,15 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // Track if we have cached data to avoid showing loading state on remount const hasCachedData = !!searchResults && Object.keys(searchResults).length > 0; - const hasCompleteCache = hasCachedData && !hasMore; + // Only treat the cache as complete when the empty-query dataset is fully loaded. + // A finished server search for a non-empty term can still be a partial result set. + const hasCompleteEmptyQueryCache = hasCachedData && !hasMore && searchQuery === ''; // Keep ref updated with latest values for use in stable callbacks - const stateRef = useRef({hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteCache, isSearching, isLoadingMore}); + const stateRef = useRef({hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteEmptyQueryCache, isSearching, isLoadingMore}); useEffect(() => { - stateRef.current = {hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteCache, isSearching, isLoadingMore}; - }, [hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteCache, isSearching, isLoadingMore]); + stateRef.current = {hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteEmptyQueryCache, isSearching, isLoadingMore}; + }, [hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteEmptyQueryCache, isSearching, isLoadingMore]); // Incremented on every new search so a cancelled request doesn't clear the loading state of its successor const requestSeqRef = useRef(0); @@ -110,10 +112,10 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { return; } - const {hasCompleteCache: currentHasCompleteCache, hasMore: currentHasMore, nextCursor: currentCursor} = stateRef.current; + const {hasCompleteEmptyQueryCache: currentHasCompleteEmptyQueryCache, hasMore: currentHasMore, nextCursor: currentCursor} = stateRef.current; - // When the full dataset is already cached, filter locally instead of hitting the server on every keystroke. - if (currentHasCompleteCache) { + // When the full empty-query dataset is already cached, filter locally instead of hitting the server on every keystroke. + if (currentHasCompleteEmptyQueryCache) { setSearchTagFiltersPagination(currentHasMore, currentCursor, query); setHasCompletedSearch(true); return; @@ -142,10 +144,10 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { }); }; - // Clear persisted pagination when the filter closes so a fresh open does not reuse a stale query. + // Clear persisted pagination and cached pages when the filter closes so a fresh open refetches with valid hasMore. useEffect(() => { return () => { - setSearchTagFiltersPagination(false, '', ''); + clearSearchTagFiltersState(); }; }, []); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 2cadb8f89bb7..38c0e56379dd 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1087,6 +1087,12 @@ function setSearchTagFiltersPagination(hasMore: boolean, nextCursor: string, sea }); } +/** Resets tag filter pagination and cached results when the filter closes. */ +function clearSearchTagFiltersState() { + setSearchTagFiltersPagination(false, '', ''); + Onyx.setCollection(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, {}); +} + function openBulkChangeApproverPage(reportIDList: OpenBulkChangeApproverPageParams['reportIDList']) { const optimisticData: Array> = [ { @@ -2444,6 +2450,7 @@ export { openSearchCategoryFiltersPage, openSearchTagFiltersPage, setSearchTagFiltersPagination, + clearSearchTagFiltersState, getPolicyFromSearchSnapshot, getReportFromSearchSnapshot, resolveSearchPayPaymentMethod, diff --git a/tests/unit/hooks/useSearchTagFiltersTest.ts b/tests/unit/hooks/useSearchTagFiltersTest.ts index d8ad946086de..095128bf2f87 100644 --- a/tests/unit/hooks/useSearchTagFiltersTest.ts +++ b/tests/unit/hooks/useSearchTagFiltersTest.ts @@ -2,13 +2,14 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; import useSearchTagFilters from '@hooks/useSearchTagFilters'; -import {openSearchTagFiltersPage, setSearchTagFiltersPagination} from '@libs/actions/Search'; +import {clearSearchTagFiltersState, openSearchTagFiltersPage, setSearchTagFiltersPagination} from '@libs/actions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; const mockOpenSearchTagFiltersPage = jest.mocked(openSearchTagFiltersPage); const mockSetSearchTagFiltersPagination = jest.mocked(setSearchTagFiltersPagination); +const mockClearSearchTagFiltersState = jest.mocked(clearSearchTagFiltersState); const onyxData: Record = {}; @@ -28,6 +29,7 @@ jest.mock('@hooks/useNetwork', () => ({ jest.mock('@libs/actions/Search', () => ({ openSearchTagFiltersPage: jest.fn(() => Promise.resolve({hasMore: false, nextCursor: ''})), setSearchTagFiltersPagination: jest.fn(), + clearSearchTagFiltersState: jest.fn(), })); jest.mock('@libs/Log', () => ({ @@ -71,7 +73,13 @@ describe('useSearchTagFilters', () => { mockIsOffline = false; mockUseOnyx.mockClear(); mockOpenSearchTagFiltersPage.mockClear().mockResolvedValue({hasMore: false, nextCursor: ''}); - mockSetSearchTagFiltersPagination.mockClear(); + mockSetSearchTagFiltersPagination.mockClear().mockImplementation((hasMore, nextCursor, searchQuery) => { + onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION] = {hasMore, nextCursor, searchQuery}; + }); + mockClearSearchTagFiltersState.mockClear().mockImplementation(() => { + delete onyxData[ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS]; + delete onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION]; + }); }); it('fetches with an empty query on mount when pagination still holds a previous search term', async () => { @@ -89,12 +97,54 @@ describe('useSearchTagFilters', () => { expect(mockOpenSearchTagFiltersPage).not.toHaveBeenCalledWith(expect.objectContaining({searchQuery: 'marketing'}), expect.anything()); }); - it('clears persisted pagination when the filter unmounts', () => { + it('clears persisted pagination and cached tags when the filter unmounts', () => { + setPartialTagFilterState('marketing'); + const {unmount} = renderHook(() => useSearchTagFilters(POLICY_ID)); unmount(); - expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', ''); + expect(mockClearSearchTagFiltersState).toHaveBeenCalledTimes(1); + }); + + it('refetches on remount after leaving the filter so pagination can continue', async () => { + setPartialTagFilterState(''); + mockOpenSearchTagFiltersPage.mockResolvedValueOnce({hasMore: true, nextCursor: 'cursor-1'}); + + const {unmount} = renderHook(() => useSearchTagFilters(POLICY_ID)); + + await waitFor(() => { + expect(mockOpenSearchTagFiltersPage).toHaveBeenCalledTimes(1); + }); + + unmount(); + + mockOpenSearchTagFiltersPage.mockClear().mockResolvedValueOnce({hasMore: true, nextCursor: 'cursor-2'}); + + const {result} = renderHook(() => useSearchTagFilters(POLICY_ID)); + + await waitFor(() => { + expect(mockOpenSearchTagFiltersPage).toHaveBeenCalledWith( + expect.objectContaining({searchQuery: '', policyIDs: POLICY_ID, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}), + true, + ); + }); + + await waitFor(() => { + expect(onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION]).toEqual(expect.objectContaining({hasMore: true, nextCursor: 'cursor-2', searchQuery: ''})); + }); + + mockOpenSearchTagFiltersPage.mockClear().mockResolvedValueOnce({hasMore: false, nextCursor: ''}); + + act(() => { + result.current.loadMore(); + }); + + await waitFor(() => { + expect(mockOpenSearchTagFiltersPage).toHaveBeenCalledWith( + expect.objectContaining({searchQuery: '', policyIDs: POLICY_ID, cursor: 'cursor-2', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}), + ); + }); }); it('re-fetches with the active search query on reconnect while the filter stays mounted', async () => { @@ -134,8 +184,43 @@ describe('useSearchTagFilters', () => { expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', 'marketing'); }); - it('resets the search query on mount without refetching when the full dataset is already cached', async () => { - setCompleteTagFilterState('marketing'); + it('refetches when clearing a server search that only cached partial results', async () => { + setPartialTagFilterState(''); + + const {result, rerender} = renderHook(() => useSearchTagFilters(POLICY_ID)); + + await waitFor(() => { + expect(mockOpenSearchTagFiltersPage).toHaveBeenCalledTimes(1); + }); + + onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION] = { + hasMore: false, + nextCursor: '', + searchQuery: 'ch', + }; + onyxData[ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS] = { + [`${ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS}${POLICY_ID}`]: { + tag1: {tagName: 'chicago'}, + tag2: {tagName: 'charlotte'}, + }, + }; + rerender({}); + mockOpenSearchTagFiltersPage.mockClear(); + + act(() => { + result.current.searchTags(''); + }); + + await waitFor(() => { + expect(mockOpenSearchTagFiltersPage).toHaveBeenCalledWith( + expect.objectContaining({searchQuery: '', policyIDs: POLICY_ID, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE}), + true, + ); + }); + }); + + it('resets the search query on mount without refetching when the full empty-query dataset is already cached', async () => { + setCompleteTagFilterState(''); renderHook(() => useSearchTagFilters(POLICY_ID)); From 5fa117c52df6363767e7de0242446c3e0c0909b0 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:19:59 +0530 Subject: [PATCH 136/485] fix(search): tag filter review and search focus --- .../Search/FilterComponents/MultiSelect.tsx | 2 +- .../Search/FilterComponents/TagSelector.tsx | 34 +++++++++++++++---- src/libs/actions/Search.ts | 16 ++++++--- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 2da421370e54..14b3f246523e 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -101,7 +101,7 @@ function MultiSelect({ const searchCallback = (term: string) => { onSearchChange?.(term); }; - const debouncedOnSearchChange = useDebounce<(term: string) => void>(searchCallback, CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + const debouncedOnSearchChange = useDebounce(searchCallback, CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); const updateSearchTerm = (text: string) => { setSearchTerm(text); diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 4e81f513d59f..3408d8c6ccea 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -1,11 +1,18 @@ import type {Filter, SearchFilterCommonProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useSearchTagFilters from '@hooks/useSearchTagFilters'; import {getCleanedTagName} from '@libs/PolicyUtils'; +import {getAllPolicyValues} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection} from 'react-native-onyx'; import React from 'react'; @@ -19,16 +26,16 @@ type TagSelectorProps = SearchFilterCommonProps & { function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); - // A negated workspace filter cannot be expressed as an inclusion list, so it falls back to searching every workspace - const policyIDs = policyID?.isNegated ? '' : (policyID?.value ?? []).join(','); + const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const policyIDs = policyID?.value?.length + ? getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY, policies) + .map((policy) => policy.id) + .join(',') + : ''; const {searchResults, isSearching, isLoadingMore, hasMore, loadMore, searchTags, isInitialLoading, searchQuery} = useSearchTagFilters(policyIDs); const tagItems: Array> = []; const emptyTagItem = {text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}; - // The empty-tag option is client-side only, so it is matched against the applied search query locally - if (!searchQuery || emptyTagItem.text.toLowerCase().includes(searchQuery.toLowerCase())) { - tagItems.push(emptyTagItem); - } const seenTagNames = new Set(); const lowerSearchQuery = searchQuery.toLowerCase(); @@ -47,6 +54,16 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti } } + const shouldShowEmptyTagOption = + !isSearching && + !isInitialLoading && + (!hasMore || seenTagNames.size <= CONST.SEARCH.TAG_FILTER_PAGE_SIZE) && + (!searchQuery || emptyTagItem.text.toLowerCase().includes(lowerSearchQuery)); + + if (shouldShowEmptyTagOption) { + tagItems.unshift(emptyTagItem); + } + const selectedTagsItems = value.map((tag) => { if (tag === CONST.SEARCH.TAG_EMPTY_VALUE) { return {text: translate('search.noTag'), value: tag}; @@ -65,11 +82,14 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti } } + // Keep search mounted while a query is active or in flight. Clearing results for a new search resets hasMore and tag count. + const shouldEnableSearch = hasMore || seenTagNames.size >= CONST.STANDARD_LIST_ITEM_LIMIT || isSearching || !!searchQuery; + return ( { if (shouldCancelPendingRequests) { HttpUtils.cancelPendingRequests(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE); - // Clear stale results immediately so the selected-tags fallback in TagSelector doesn't append - // selected tags after stale search results, which would reorder the list before the API responds. - Onyx.setCollection(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, {}); } - return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}).then((response) => { + + const optimisticData: AnyOnyxUpdate[] = shouldCancelPendingRequests + ? [ + { + onyxMethod: Onyx.METHOD.SET_COLLECTION, + key: ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, + value: {}, + }, + ] + : []; + + return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}, {optimisticData}).then((response) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OpenSearchTagFiltersPage response fields are command-specific and not declared on the shared Response type const tagFiltersResponse = response as OpenSearchTagFiltersPageResponse | undefined; return { From e448c848cbf40826622306eea568319fab5b8147 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:36:33 +0530 Subject: [PATCH 137/485] fix(search): offline tag filter POLICY_TAGS fallback --- .../Search/FilterComponents/TagSelector.tsx | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 3408d8c6ccea..6b6a28e153be 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -1,15 +1,16 @@ import type {Filter, SearchFilterCommonProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useSearchTagFilters from '@hooks/useSearchTagFilters'; -import {getCleanedTagName} from '@libs/PolicyUtils'; +import {getCleanedTagName, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; import {getAllPolicyValues} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy} from '@src/types/onyx'; +import type {Policy, PolicyTagLists} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection} from 'react-native-onyx'; @@ -26,7 +27,9 @@ type TagSelectorProps = SearchFilterCommonProps & { function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [allPolicyTags = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const policyIDs = policyID?.value?.length ? getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY, policies) .map((policy) => policy.id) @@ -34,31 +37,46 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti : ''; const {searchResults, isSearching, isLoadingMore, hasMore, loadMore, searchTags, isInitialLoading, searchQuery} = useSearchTagFilters(policyIDs); + const hasSearchResults = !!searchResults && Object.keys(searchResults).length > 0; + const shouldUseOfflineFallback = isOffline && !hasSearchResults; + const tagItems: Array> = []; const emptyTagItem = {text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}; const seenTagNames = new Set(); const lowerSearchQuery = searchQuery.toLowerCase(); - // Preserve backend order - new items append at end for infinite scroll. - // When offline the API is skipped, so cached results are filtered locally by the search query. - for (const policyTags of Object.values(searchResults ?? {})) { - for (const tag of Object.values(policyTags ?? {})) { - if (seenTagNames.has(tag.tagName)) { - continue; + const addTagName = (tagName: string) => { + if (seenTagNames.has(tagName)) { + return; + } + if (lowerSearchQuery && !tagName.toLowerCase().includes(lowerSearchQuery)) { + return; + } + seenTagNames.add(tagName); + tagItems.push({text: getCleanedTagName(tagName), value: tagName}); + }; + + if (shouldUseOfflineFallback) { + // Fall back to synced workspace tag data when the paginated search cache is empty offline. + const policyTagsLists = getAllPolicyValues(policyID?.value?.length ? policyID : undefined, ONYXKEYS.COLLECTION.POLICY_TAGS, allPolicyTags); + for (const policyTagsList of policyTagsLists) { + for (const tagName of getTagNamesFromTagsLists(policyTagsList)) { + addTagName(tagName); } - if (lowerSearchQuery && !tag.tagName.toLowerCase().includes(lowerSearchQuery)) { - continue; + } + } else { + // Preserve backend order - new items append at end for infinite scroll. + // When offline the API is skipped, so cached results are filtered locally by the search query. + for (const policyTags of Object.values(searchResults ?? {})) { + for (const tag of Object.values(policyTags ?? {})) { + addTagName(tag.tagName); } - seenTagNames.add(tag.tagName); - tagItems.push({text: getCleanedTagName(tag.tagName), value: tag.tagName}); } } - const shouldShowEmptyTagOption = - !isSearching && - !isInitialLoading && - (!hasMore || seenTagNames.size <= CONST.SEARCH.TAG_FILTER_PAGE_SIZE) && - (!searchQuery || emptyTagItem.text.toLowerCase().includes(lowerSearchQuery)); + const isCompleteTagList = shouldUseOfflineFallback || !hasMore || seenTagNames.size <= CONST.SEARCH.TAG_FILTER_PAGE_SIZE; + + const shouldShowEmptyTagOption = !isSearching && !isInitialLoading && isCompleteTagList && (!searchQuery || emptyTagItem.text.toLowerCase().includes(lowerSearchQuery)); if (shouldShowEmptyTagOption) { tagItems.unshift(emptyTagItem); @@ -83,7 +101,9 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti } // Keep search mounted while a query is active or in flight. Clearing results for a new search resets hasMore and tag count. - const shouldEnableSearch = hasMore || seenTagNames.size >= CONST.STANDARD_LIST_ITEM_LIMIT || isSearching || !!searchQuery; + const shouldEnableSearch = shouldUseOfflineFallback + ? seenTagNames.size >= CONST.STANDARD_LIST_ITEM_LIMIT || !!searchQuery + : hasMore || seenTagNames.size >= CONST.STANDARD_LIST_ITEM_LIMIT || isSearching || !!searchQuery; return ( onChange(tags.map((tag) => tag.value))} - onEndReached={hasMore ? loadMore : undefined} + onEndReached={hasMore && !shouldUseOfflineFallback ? loadMore : undefined} onSearchChange={searchTags} loading={isInitialLoading} isSearching={isSearching} From 1e01b8be73ba147c2f2bce1a423ae4a71834dff3 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:47:59 +0530 Subject: [PATCH 138/485] Update useSearchTagFiltersTest.ts --- tests/unit/hooks/useSearchTagFiltersTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/hooks/useSearchTagFiltersTest.ts b/tests/unit/hooks/useSearchTagFiltersTest.ts index 095128bf2f87..0aa5277ca63c 100644 --- a/tests/unit/hooks/useSearchTagFiltersTest.ts +++ b/tests/unit/hooks/useSearchTagFiltersTest.ts @@ -107,7 +107,7 @@ describe('useSearchTagFilters', () => { expect(mockClearSearchTagFiltersState).toHaveBeenCalledTimes(1); }); - it('refetches on remount after leaving the filter so pagination can continue', async () => { + it('re-fetches on remount after leaving the filter so pagination can continue', async () => { setPartialTagFilterState(''); mockOpenSearchTagFiltersPage.mockResolvedValueOnce({hasMore: true, nextCursor: 'cursor-1'}); @@ -184,7 +184,7 @@ describe('useSearchTagFilters', () => { expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', 'marketing'); }); - it('refetches when clearing a server search that only cached partial results', async () => { + it('re-fetches when clearing a server search that only cached partial results', async () => { setPartialTagFilterState(''); const {result, rerender} = renderHook(() => useSearchTagFilters(POLICY_ID)); From 1efc95025b8680d5bc23560ca5f746c92816ef6f Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 7 Sep 2026 12:27:38 -0700 Subject: [PATCH 139/485] Address review nits - Restore the missing-ID guard on requestParentReportAction in DynamicReportDetailsPage so the MONEY_REPORT branch skips a scan that could only ever miss, matching the pre-refactor useMemo. - Copy years before sorting in YearPickerModal. With an empty search the list is the years prop itself, so the in-place sort mutated the caller's state during render. - Drop the stale useMemo reference from the CategorySettingsPage comment. --- .../DatePicker/CalendarPicker/YearPickerModal.tsx | 3 ++- src/pages/DynamicReportDetailsPage.tsx | 6 +++++- src/pages/workspace/categories/CategorySettingsPage.tsx | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index 19e149ba81cb..d7161fc92dd4 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -41,7 +41,8 @@ function YearPickerModal({isVisible, years, currentYear, onYearChange, onClose, const [searchText, setSearchText] = useState(''); const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); const headerMessage = !yearsList.length ? translate('common.noResultsFound') : ''; - const data = yearsList.sort((a, b) => b.value - a.value); + // Copy before sorting: with an empty search `yearsList` is the `years` prop itself, and sorting it in place would mutate the caller's state during render. + const data = [...yearsList].sort((a, b) => b.value - a.value); useEffect(() => { if (isVisible) { diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 1ec156086a5d..e707e1352cf2 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -319,7 +319,11 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; const transactionThreadParentReportActionID = transactionThreadReport?.parentReportActionID; - const requestParentReportAction = caseID === CASES.MONEY_REPORT ? reportActions?.find((action) => action.reportActionID === transactionThreadParentReportActionID) : parentReportAction; + // Without an ID to match, the scan can only ever miss, so skip it rather than walking every action. + const transactionThreadParentReportAction = transactionThreadParentReportActionID + ? reportActions?.find((action) => action.reportActionID === transactionThreadParentReportActionID) + : undefined; + const requestParentReportAction = caseID === CASES.MONEY_REPORT ? transactionThreadParentReportAction : parentReportAction; const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(requestParentReportAction); const [iouPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`); const [requestParentReportActionChildReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(requestParentReportAction?.childReportID)}`); diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 6a0511487027..5e9f9966359a 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -435,7 +435,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti )} {/* * Legacy category rule entry points. When removing the RULES_REVAMP beta, - * delete this entire non-revamp block (and related useMemo values) instead + * delete this entire non-revamp block (and the values it reads) instead * of keeping these Category settings routes. */} From a20ed082301304afdcacde38dda408b345dba3eb Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 8 Sep 2026 10:35:42 +0700 Subject: [PATCH 140/485] fix: conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 1cbcddc41201..57708e6b576b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 1cbcddc41201c00550fefc52f5ac0c5675e123b0 +Subproject commit 57708e6b576bfb38ca22dc596f0c4b47a22d6319 From 0f15a73113b5e00026bb0ae357f8b2dfa15e70e7 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 8 Sep 2026 10:46:04 +0700 Subject: [PATCH 141/485] fix: conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 1cbcddc41201..57708e6b576b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 1cbcddc41201c00550fefc52f5ac0c5675e123b0 +Subproject commit 57708e6b576bfb38ca22dc596f0c4b47a22d6319 From aa6fed71c0d23eb2713c04c8c758170617acdf0c Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 8 Sep 2026 11:03:24 +0700 Subject: [PATCH 142/485] fix: type --- tests/perf-test/OptionsListUtils.perf-test.ts | 3 ++ tests/unit/OptionsListUtilsTest.tsx | 49 +++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index dbc8a7b7c711..132c96be0114 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -183,6 +183,7 @@ describe('OptionsListUtils', () => { await measureFunction(() => { filterAndOrderOptions(formattedOptions, SEARCH_VALUE, COUNTRY_CODE, loginList, MOCK_CURRENT_USER_EMAIL, MOCK_CURRENT_USER_ACCOUNT_ID, personalDetails, { dateFnsLocale: undefined, + currentUserAccountID: MOCK_CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); }); @@ -203,6 +204,7 @@ describe('OptionsListUtils', () => { await measureFunction(() => { filterAndOrderOptions(formattedOptions, '', COUNTRY_CODE, loginList, MOCK_CURRENT_USER_EMAIL, MOCK_CURRENT_USER_ACCOUNT_ID, personalDetails, { dateFnsLocale: undefined, + currentUserAccountID: MOCK_CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); }); @@ -378,6 +380,7 @@ describe('OptionsListUtils', () => { await measureFunction(() => { filterAndOrderOptions(formattedOptions, 'Email Report Five', COUNTRY_CODE, loginList, MOCK_CURRENT_USER_EMAIL, MOCK_CURRENT_USER_ACCOUNT_ID, largePersonalDetails, { dateFnsLocale: undefined, + currentUserAccountID: MOCK_CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); }); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 951843d2717a..645a8fadfc91 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1375,6 +1375,7 @@ describe('OptionsListUtils', () => { const filteredOptions = filterAndOrderOptions(preFilteredOptions, searchString, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, personalDetails, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -1908,6 +1909,7 @@ describe('OptionsListUtils', () => { ); const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3079,6 +3081,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with an empty search value const filteredOptions = filterAndOrderOptions(options, '', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3148,6 +3151,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3190,6 +3194,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3262,6 +3267,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3293,6 +3299,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3333,6 +3340,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value const filterOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3364,6 +3372,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3397,6 +3406,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3526,6 +3536,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions(options, searchText, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3575,6 +3586,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value that does not match the group chat name const filteredOptions = filterAndOrderOptions(options, 'mutants', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3627,6 +3639,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value that matches the group chat name const filteredOptions = filterAndOrderOptions(options, 'Avengers Room', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3679,6 +3692,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with a search value that does not match the group chat name const filteredOptions = filterAndOrderOptions(options, 'Mutants Lair', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3702,6 +3716,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that matches a personal detail with no existing report const filteredOptions = filterAndOrderOptions(options, 'hulk', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -3990,6 +4005,7 @@ describe('OptionsListUtils', () => { // When we pass the returned options to filterAndOrderOptions with any search value const filteredOptions = filterAndOrderOptions(options, 'Unknown', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4014,6 +4030,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that does not match any personal details or reports const filteredOptions = filterAndOrderOptions(options, 'marc@expensify', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4040,6 +4057,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that does not match any personal details or reports const filteredOptions = filterAndOrderOptions(options, 'marc@expensify.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4066,6 +4084,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite const filteredOptions = filterAndOrderOptions(options, 'peter.parker@expensify.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4091,6 +4110,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value without accent mark const filteredOptions = filterAndOrderOptions(options, 'Timothee', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4114,6 +4134,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite const filteredOptions = filterAndOrderOptions(options, '5005550006', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4142,6 +4163,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite const filteredOptions = filterAndOrderOptions(options, '+15005550006', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4170,6 +4192,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite const filteredOptions = filterAndOrderOptions(options, '+1 (800)324-3233', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4198,6 +4221,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that does not match any personal details or reports const filteredOptions = filterAndOrderOptions(options, '998243aaaa', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4276,6 +4300,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that does not match any personal details const filteredOptions = filterAndOrderOptions(options, 'magneto', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4364,6 +4389,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that matches a personal detail const filteredOptions = filterAndOrderOptions(options, 'spider', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4393,6 +4419,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value that matches multiple items const filteredOptions = filterAndOrderOptions(options, 'fantastic', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4474,6 +4501,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a an empty search value const filteredOptions = filterAndOrderOptions(options, '', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); const matchingEntries = filteredOptions.personalDetails.filter((detail) => detail.login === login); @@ -4513,6 +4541,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions(options, searchTerm, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -4540,10 +4569,12 @@ describe('OptionsListUtils', () => { const multiSpaceQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); const spaceSeparatedQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); @@ -8584,6 +8615,7 @@ describe('OptionsListUtils', () => { it('should return a valid invite option for a new email', () => { const result = getUserToInviteOption({ dateFnsLocale: undefined, + convertToDisplayString, searchValue: 'newuser@example.com', personalDetails: PERSONAL_DETAILS, loginList: {}, @@ -8599,6 +8631,7 @@ describe('OptionsListUtils', () => { it('should return null when searchValue is empty', () => { const result = getUserToInviteOption({ dateFnsLocale: undefined, + convertToDisplayString, searchValue: '', personalDetails: PERSONAL_DETAILS, loginList: {}, @@ -8612,6 +8645,7 @@ describe('OptionsListUtils', () => { it('should return null when searching for current user email', () => { const result = getUserToInviteOption({ dateFnsLocale: undefined, + convertToDisplayString, searchValue: CURRENT_USER_EMAIL, personalDetails: PERSONAL_DETAILS, loginList: {}, @@ -8633,13 +8667,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, ); const filteredOptions = filterAndOrderOptions(options, 'newuser@example.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + convertToDisplayString, }); expect(filteredOptions.userToInvite).not.toBeNull(); @@ -8656,11 +8691,15 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, ); - const filteredOptions = filterAndOrderOptions(options, 'anotheruser@example.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const filteredOptions = filterAndOrderOptions(options, 'anotheruser@example.com', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { + dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + convertToDisplayString, + }); expect(filteredOptions.userToInvite).not.toBeNull(); expect(filteredOptions.userToInvite?.login).toBe('anotheruser@example.com'); @@ -8675,13 +8714,14 @@ describe('OptionsListUtils', () => { CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, - {dateFnsLocale: undefined}, + {dateFnsLocale: undefined, convertToDisplayString}, translateLocal, ); const filteredOptions = filterAndOrderOptions(options, CURRENT_USER_EMAIL, COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + convertToDisplayString, }); expect(filteredOptions.userToInvite).toBeNull(); @@ -8726,6 +8766,7 @@ describe('OptionsListUtils', () => { // When we call filterAndOrderOptions with the reports parameter const filteredOptions = filterAndOrderOptions(options, 'spider', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS, { dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, convertToDisplayString, }); From e27df423ebcc1c397742cc779c218c508de7772d Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 8 Sep 2026 11:05:41 +0700 Subject: [PATCH 143/485] fix: remove redundant change --- src/libs/OptionsListUtils/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 1776da796af4..8fe546fe4dde 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2822,7 +2822,7 @@ function filterUserToInvite( config: FilterUserToInviteConfig, countryCode: number = CONST.DEFAULT_COUNTRY_CODE, ): SearchOptionData | null { - const {canInviteUser = true, excludeLogins = {}, dateFnsLocale, currentUserAccountID} = config; + const {canInviteUser = true, excludeLogins = {}, currentUserAccountID} = config; if (!canInviteUser) { return null; } @@ -2850,7 +2850,6 @@ function filterUserToInvite( loginList, currentUserEmail, ...config, - dateFnsLocale, currentUserAccountID, }); } From 1b86714864f36ca850037bc6fed05b7f2fde1ec3 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 8 Sep 2026 11:09:26 +0700 Subject: [PATCH 144/485] fix: revert redundant change --- src/libs/OptionsListUtils/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 8fe546fe4dde..03719b9a577e 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2380,6 +2380,7 @@ function getValidOptions( loginList, currentUserEmail, personalDetails, + countryCode, { dateFnsLocale, convertToDisplayString: config.convertToDisplayString, @@ -2388,7 +2389,6 @@ function getValidOptions( searchInputValue, currentUserAccountID, }, - countryCode, ); } @@ -2819,8 +2819,8 @@ function filterUserToInvite( loginList: OnyxEntry, currentUserEmail: string, personalDetails: OnyxEntry, + countryCode: number, config: FilterUserToInviteConfig, - countryCode: number = CONST.DEFAULT_COUNTRY_CODE, ): SearchOptionData | null { const {canInviteUser = true, excludeLogins = {}, currentUserAccountID} = config; if (!canInviteUser) { @@ -2888,12 +2888,12 @@ function filterOptions( loginList, currentUserEmail, personalDetailsCollection, + countryCode, { ...config, searchInputValue: searchInputValueForInvite, currentUserAccountID, }, - countryCode, ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); From 062a4137e44bb0fcdecc6b88efc8c62f55282a61 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 8 Sep 2026 12:59:45 +0200 Subject: [PATCH 145/485] Drop unused guidedSetupAndTourStatusSelector import --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 95dc035a050c..4a21f5fd7979 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -41,7 +41,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {LayoutChangeEvent} from 'react-native'; import {useIsFocused, useRoute} from '@react-navigation/native'; -import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; From 806b91a13636200b27ce8dc5adf4e29d4e8f3002 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:54:59 +0530 Subject: [PATCH 146/485] Update MultiSelect.tsx --- src/components/Search/FilterComponents/MultiSelect.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 14b3f246523e..b36201e53348 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -130,7 +130,7 @@ function MultiSelect({ leftElement: item.leftElement, })); - const isSearchInProgress = isSearching || isLoading; + const isSearchInProgress = isSearching ?? isLoading; const shouldShowSearchSpinner = isSearching && listData.length === 0; const headerMessage = isSearchable && listData.length === 0 && !isSearchInProgress ? translate('common.noResultsFound') : undefined; From 9792771c17606b910455249fa224aaabf08656d3 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:55:37 +0530 Subject: [PATCH 147/485] Update useSearchTagFilters.ts --- src/hooks/useSearchTagFilters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 5e03ba253143..07d7c18ef6df 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -144,7 +144,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { }); }; - // Clear persisted pagination and cached pages when the filter closes so a fresh open refetches with valid hasMore. + // Clear persisted pagination and cached pages when the filter closes so a fresh open re-fetches with valid hasMore. useEffect(() => { return () => { clearSearchTagFiltersState(); From c8d92db5bf8f50e43262ea00cbe40e8f74b4a46c Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:31:52 +0530 Subject: [PATCH 148/485] Address review feedback for paginated tag filters --- .../Search/FilterComponents/MultiSelect.tsx | 60 ++++++++----------- .../Search/FilterComponents/TagSelector.tsx | 22 +++---- src/hooks/useSearchTagFilters.ts | 32 ++++++++-- src/libs/actions/Search.ts | 5 +- tests/unit/hooks/useSearchTagFiltersTest.ts | 25 ++++++++ 5 files changed, 95 insertions(+), 49 deletions(-) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index b36201e53348..1458d2e9a4e0 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -5,7 +5,6 @@ import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectL import type {ListItem} from '@components/SelectionList/ListItem/types'; import type {TextInputOptions} from '@components/SelectionList/types'; -import useDebounce from '@hooks/useDebounce'; import useDebouncedState from '@hooks/useDebouncedState'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; @@ -19,7 +18,7 @@ import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {ReactNode} from 'react'; -import React, {useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import ListFilterView from './ListFilterViewWrapper'; @@ -63,9 +62,6 @@ type MultiSelectProps = SearchFilterCommonProps>> & /** Whether more items are being loaded (shows spinner at bottom of list) */ isLoadingMore?: boolean; - /** Whether a search is in progress (suppresses "No results found" while true) */ - isLoading?: boolean; - /** Whether a server search is in progress (shows a centered spinner in the list area) */ isSearching?: boolean; }; @@ -87,7 +83,6 @@ function MultiSelect({ onEndReached, onSearchChange, isLoadingMore, - isLoading, isSearching, }: MultiSelectProps) { const theme = useTheme(); @@ -97,16 +92,19 @@ function MultiSelect({ const [selectedItems, setSelectedItems] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - // Server-side search is fired from the text change handler so typing and the API call stay in the same event path - const searchCallback = (term: string) => { - onSearchChange?.(term); - }; - const debouncedOnSearchChange = useDebounce(searchCallback, CONST.TIMING.USE_DEBOUNCED_STATE_DELAY); + const onSearchChangeRef = useRef(onSearchChange); + useEffect(() => { + onSearchChangeRef.current = onSearchChange; + }, [onSearchChange]); - const updateSearchTerm = (text: string) => { - setSearchTerm(text); - debouncedOnSearchChange(text); - }; + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + onSearchChangeRef.current?.(debouncedSearchTerm); + }, [debouncedSearchTerm]); // Snapshot the values selected when the filter first opened so they can be floated to the top of a long list on // first render without repinning rows that are toggled afterwards. @@ -130,9 +128,8 @@ function MultiSelect({ leftElement: item.leftElement, })); - const isSearchInProgress = isSearching ?? isLoading; const shouldShowSearchSpinner = isSearching && listData.length === 0; - const headerMessage = isSearchable && listData.length === 0 && !isSearchInProgress ? translate('common.noResultsFound') : undefined; + const headerMessage = isSearchable && listData.length === 0 && !isSearching ? translate('common.noResultsFound') : undefined; const updateSelectedItems = (item: ListItem) => { if (item.isSelected) { @@ -154,7 +151,7 @@ function MultiSelect({ const textInputOptions: TextInputOptions = { value: searchTerm, label: isSearchable ? (searchPlaceholder ?? translate('common.search')) : undefined, - onChangeText: updateSearchTerm, + onChangeText: setSearchTerm, headerMessage, style: { containerStyle: selectionListTextInputStyle, @@ -162,6 +159,15 @@ function MultiSelect({ disableAutoFocus: !autoFocus, }; + const renderCenteredSpinner = () => ( + + + + ); + const footerContent = ( <> {!!isLoadingMore && ( @@ -184,12 +190,7 @@ function MultiSelect({ isNegatable={isNegatable} > {loading ? ( - - - + renderCenteredSpinner() ) : ( ({ style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} footerContent={footerContent} onEndReached={onEndReached} - listEmptyContent={ - shouldShowSearchSpinner ? ( - - - - ) : undefined - } + listEmptyContent={shouldShowSearchSpinner ? renderCenteredSpinner() : undefined} shouldShowListEmptyContent={shouldShowSearchSpinner} /> )} diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 6b6a28e153be..40fa8fc75815 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -6,7 +6,7 @@ import useOnyx from '@hooks/useOnyx'; import useSearchTagFilters from '@hooks/useSearchTagFilters'; import {getCleanedTagName, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; -import {getAllPolicyValues} from '@libs/SearchQueryUtils'; +import {getAllPolicyValues, sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -26,7 +26,7 @@ type TagSelectorProps = SearchFilterCommonProps & { }; function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { - const {translate} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const {isOffline} = useNetwork(); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allPolicyTags = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); @@ -74,9 +74,9 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti } } - const isCompleteTagList = shouldUseOfflineFallback || !hasMore || seenTagNames.size <= CONST.SEARCH.TAG_FILTER_PAGE_SIZE; + tagItems.sort((a, b) => sortOptionsWithEmptyValue(a.text.toString(), b.text.toString(), localeCompare)); - const shouldShowEmptyTagOption = !isSearching && !isInitialLoading && isCompleteTagList && (!searchQuery || emptyTagItem.text.toLowerCase().includes(lowerSearchQuery)); + const shouldShowEmptyTagOption = !isSearching && !isInitialLoading && (!searchQuery || emptyTagItem.text.toLowerCase().includes(lowerSearchQuery)); if (shouldShowEmptyTagOption) { tagItems.unshift(emptyTagItem); @@ -90,13 +90,15 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti }); // Selected tags that are not in the current result page stay visible once the search is cleared, so the selection doesn't disappear from the list. - // Gated on !isSearching so stale results during a fetch don't cause selected tags to be appended in the wrong order. - if (!searchQuery && !isSearching) { + // Prepend missing selected items to the top (after "No tag") so they don't land at the bottom of the paginated list. + // Do not gate on !isSearching so they stay mounted during refetches without flashing. + if (!searchQuery) { const itemValues = new Set(tagItems.map((item) => item.value)); - for (const selectedItem of selectedTagsItems) { - if (!itemValues.has(selectedItem.value)) { - tagItems.push(selectedItem); - } + const missingSelectedItems = selectedTagsItems.filter((selectedItem) => !itemValues.has(selectedItem.value)).toSorted((a, b) => localeCompare(a.text.toString(), b.text.toString())); + + if (missingSelectedItems.length > 0) { + const insertIndex = shouldShowEmptyTagOption ? 1 : 0; + tagItems.splice(insertIndex, 0, ...missingSelectedItems); } } diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index 07d7c18ef6df..fe89499f2e13 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -12,6 +12,9 @@ import {useEffect, useRef, useState} from 'react'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; +/** + * Hook for managing paginated tag filter search. + */ type UseSearchTagFiltersResult = { /** The paginated tag search results from Onyx, keyed by full Onyx key (searchPolicyTags_) */ searchResults: OnyxCollection; @@ -66,9 +69,17 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // Track if we have cached data to avoid showing loading state on remount const hasCachedData = !!searchResults && Object.keys(searchResults).length > 0; + const isFilteringLocallyRef = useRef(false); + + const prevPolicyIDsRef = useRef(policyIDs); + if (prevPolicyIDsRef.current !== policyIDs) { + prevPolicyIDsRef.current = policyIDs; + isFilteringLocallyRef.current = false; + } + // Only treat the cache as complete when the empty-query dataset is fully loaded. // A finished server search for a non-empty term can still be a partial result set. - const hasCompleteEmptyQueryCache = hasCachedData && !hasMore && searchQuery === ''; + const hasCompleteEmptyQueryCache = hasCachedData && !hasMore && (searchQuery === '' || isFilteringLocallyRef.current); // Keep ref updated with latest values for use in stable callbacks const stateRef = useRef({hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteEmptyQueryCache, isSearching, isLoadingMore}); @@ -112,15 +123,17 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { return; } - const {hasCompleteEmptyQueryCache: currentHasCompleteEmptyQueryCache, hasMore: currentHasMore, nextCursor: currentCursor} = stateRef.current; + const {hasCompleteEmptyQueryCache: currentHasCompleteEmptyQueryCache} = stateRef.current; // When the full empty-query dataset is already cached, filter locally instead of hitting the server on every keystroke. if (currentHasCompleteEmptyQueryCache) { - setSearchTagFiltersPagination(currentHasMore, currentCursor, query); + isFilteringLocallyRef.current = true; + setSearchTagFiltersPagination(false, '', query); setHasCompletedSearch(true); return; } + isFilteringLocallyRef.current = false; const requestSeq = ++requestSeqRef.current; // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor @@ -147,6 +160,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // Clear persisted pagination and cached pages when the filter closes so a fresh open re-fetches with valid hasMore. useEffect(() => { return () => { + isFilteringLocallyRef.current = false; clearSearchTagFiltersState(); }; }, []); @@ -154,9 +168,11 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // Fetch the first page on mount, when the workspace scope changes, and on reconnect. // Skips the fetch while offline and re-fetches on reconnect, matching useLoadSearchCategoryData. // Reconnect uses the active search query so results stay in sync with the search input. - // searchTags is not memoized, so it cannot be in the dependency array — it would fire on every render. + // searchTags is not memoized, so it cannot be in the dependency array. It would fire on every render. // searchTags reads latest state via refs, so the closure captured here is safe to call. useEffect(() => { + let isCancelled = false; + if (isOffline) { prevWasOfflineRef.current = true; return; @@ -166,6 +182,10 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { prevWasOfflineRef.current = false; Promise.resolve().then(() => { + if (isCancelled) { + return; + } + if (wasOffline) { searchTags(stateRef.current.searchQuery); return; @@ -173,6 +193,10 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { searchTags(''); }); + + return () => { + isCancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [policyIDs, isOffline]); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 45e5b9e5a310..df8fda6b421e 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -10,6 +10,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type {ReportSubmitToPopoverOpenOptions} from '@hooks/useReportSubmitToPopover'; import {getExportLabelForConnection} from '@libs/AccountingUtils'; +import * as API from '@libs/API'; import {makeRequestWithSideEffects, read, waitForWrites, write} from '@libs/API'; import type { ExportSearchItemsToCSVParams, @@ -1073,7 +1074,9 @@ function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams, should ] : []; - return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}, {optimisticData}).then((response) => { + // The response body contains pagination metadata (hasMore, nextCursor) needed immediately to drive tag filter infinite scroll. + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}, {optimisticData}).then((response) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OpenSearchTagFiltersPage response fields are command-specific and not declared on the shared Response type const tagFiltersResponse = response as OpenSearchTagFiltersPageResponse | undefined; return { diff --git a/tests/unit/hooks/useSearchTagFiltersTest.ts b/tests/unit/hooks/useSearchTagFiltersTest.ts index 0aa5277ca63c..44474fe685b8 100644 --- a/tests/unit/hooks/useSearchTagFiltersTest.ts +++ b/tests/unit/hooks/useSearchTagFiltersTest.ts @@ -184,6 +184,31 @@ describe('useSearchTagFilters', () => { expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', 'marketing'); }); + it('does not call the API on consecutive keystrokes when searching with a complete cached dataset', async () => { + setCompleteTagFilterState(''); + + const {result} = renderHook(() => useSearchTagFilters(POLICY_ID)); + + await act(async () => { + await Promise.resolve(); + }); + mockOpenSearchTagFiltersPage.mockClear(); + + act(() => { + result.current.searchTags('m'); + }); + + expect(mockOpenSearchTagFiltersPage).not.toHaveBeenCalled(); + expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', 'm'); + + act(() => { + result.current.searchTags('ma'); + }); + + expect(mockOpenSearchTagFiltersPage).not.toHaveBeenCalled(); + expect(mockSetSearchTagFiltersPagination).toHaveBeenCalledWith(false, '', 'ma'); + }); + it('re-fetches when clearing a server search that only cached partial results', async () => { setPartialTagFilterState(''); From 60dee31eac2e0a74890a8054af20e565c2e424ae Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:37:57 +0530 Subject: [PATCH 149/485] Fix React Compiler ref error and spelling --- .../Search/FilterComponents/TagSelector.tsx | 2 +- src/hooks/useSearchTagFilters.ts | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 40fa8fc75815..feeed02c9ffe 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -91,7 +91,7 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti // Selected tags that are not in the current result page stay visible once the search is cleared, so the selection doesn't disappear from the list. // Prepend missing selected items to the top (after "No tag") so they don't land at the bottom of the paginated list. - // Do not gate on !isSearching so they stay mounted during refetches without flashing. + // Do not gate on !isSearching so they stay mounted during re-fetches without flashing. if (!searchQuery) { const itemValues = new Set(tagItems.map((item) => item.value)); const missingSelectedItems = selectedTagsItems.filter((selectedItem) => !itemValues.has(selectedItem.value)).toSorted((a, b) => localeCompare(a.text.toString(), b.text.toString())); diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index fe89499f2e13..f0e47a1187f8 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -69,17 +69,15 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // Track if we have cached data to avoid showing loading state on remount const hasCachedData = !!searchResults && Object.keys(searchResults).length > 0; - const isFilteringLocallyRef = useRef(false); + const [isFilteringLocally, setIsFilteringLocally] = useState(false); - const prevPolicyIDsRef = useRef(policyIDs); - if (prevPolicyIDsRef.current !== policyIDs) { - prevPolicyIDsRef.current = policyIDs; - isFilteringLocallyRef.current = false; - } + useEffect(() => { + setIsFilteringLocally(false); + }, [policyIDs]); // Only treat the cache as complete when the empty-query dataset is fully loaded. // A finished server search for a non-empty term can still be a partial result set. - const hasCompleteEmptyQueryCache = hasCachedData && !hasMore && (searchQuery === '' || isFilteringLocallyRef.current); + const hasCompleteEmptyQueryCache = hasCachedData && !hasMore && (searchQuery === '' || isFilteringLocally); // Keep ref updated with latest values for use in stable callbacks const stateRef = useRef({hasMore, nextCursor, searchQuery, hasCachedData, hasCompleteEmptyQueryCache, isSearching, isLoadingMore}); @@ -127,13 +125,13 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // When the full empty-query dataset is already cached, filter locally instead of hitting the server on every keystroke. if (currentHasCompleteEmptyQueryCache) { - isFilteringLocallyRef.current = true; + setIsFilteringLocally(true); setSearchTagFiltersPagination(false, '', query); setHasCompletedSearch(true); return; } - isFilteringLocallyRef.current = false; + setIsFilteringLocally(false); const requestSeq = ++requestSeqRef.current; // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor @@ -160,7 +158,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // Clear persisted pagination and cached pages when the filter closes so a fresh open re-fetches with valid hasMore. useEffect(() => { return () => { - isFilteringLocallyRef.current = false; + setIsFilteringLocally(false); clearSearchTagFiltersState(); }; }, []); From 8ae4c9cf48d48e3aad74fabaa6835d6084fd92dc Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:00:59 +0530 Subject: [PATCH 150/485] Fix ESLint and React hooks in effect violations --- src/hooks/useSearchTagFilters.ts | 6 ++++-- src/libs/actions/Search.ts | 4 +--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index f0e47a1187f8..ef5f6abe329b 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -69,11 +69,13 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { // Track if we have cached data to avoid showing loading state on remount const hasCachedData = !!searchResults && Object.keys(searchResults).length > 0; + const [prevPolicyIDs, setPrevPolicyIDs] = useState(policyIDs); const [isFilteringLocally, setIsFilteringLocally] = useState(false); - useEffect(() => { + if (prevPolicyIDs !== policyIDs) { + setPrevPolicyIDs(policyIDs); setIsFilteringLocally(false); - }, [policyIDs]); + } // Only treat the cache as complete when the empty-query dataset is fully loaded. // A finished server search for a non-empty term can still be a partial result set. diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index df8fda6b421e..e82ca79d503b 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -10,7 +10,6 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type {ReportSubmitToPopoverOpenOptions} from '@hooks/useReportSubmitToPopover'; import {getExportLabelForConnection} from '@libs/AccountingUtils'; -import * as API from '@libs/API'; import {makeRequestWithSideEffects, read, waitForWrites, write} from '@libs/API'; import type { ExportSearchItemsToCSVParams, @@ -1075,8 +1074,7 @@ function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams, should : []; // The response body contains pagination metadata (hasMore, nextCursor) needed immediately to drive tag filter infinite scroll. - // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}, {optimisticData}).then((response) => { + return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}, {optimisticData}).then((response) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OpenSearchTagFiltersPage response fields are command-specific and not declared on the shared Response type const tagFiltersResponse = response as OpenSearchTagFiltersPageResponse | undefined; return { From b036a00d839c1881fbb105717ce23bb9b0894260 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:06:36 +0530 Subject: [PATCH 151/485] feat: consume globally sorted tag filters from backend --- src/ONYXKEYS.ts | 6 ++-- .../Search/FilterComponents/TagSelector.tsx | 13 ++++----- src/hooks/useSearchTagFilters.ts | 10 +++---- src/libs/actions/Search.ts | 8 +++--- src/setup/index.ts | 2 +- src/types/onyx/PolicyTag.ts | 11 +++++++- src/types/onyx/index.ts | 3 +- tests/unit/hooks/useSearchTagFiltersTest.ts | 28 +++++++------------ 8 files changed, 40 insertions(+), 41 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 287c568a2a6a..07d421d986e3 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -52,6 +52,9 @@ const ONYXKEYS = { /** Pagination state for tag filter search (hasMore, nextCursor). RAM-only so it resets on app restart. */ RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION: 'searchTagFiltersPagination', + /** Paginated tag search results. RAM-only so it resets on app restart. */ + RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS: 'searchTagFiltersResults', + /** Boolean flag indicating a SignInWithShortLivedAuthToken request is in flight. RAM-only so an interrupted request never persists a stuck `true` to IndexedDB and blocks future reauth attempts. */ RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN: 'isAuthenticatingWithShortLivedToken', @@ -891,7 +894,6 @@ const ONYXKEYS = { POLICY_CATEGORIES_DRAFT: 'policyCategoriesDraft_', POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', - SEARCH_POLICY_TAGS: 'searchPolicyTags_', POLICY_VENDORS: 'policyVendors_', POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', POLICY_RECENTLY_USED_DESTINATIONS: 'nvp_recentlyUsedDestinations_', @@ -1496,7 +1498,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagLists; - [ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS]: OnyxTypes.SearchPolicyTags; [ONYXKEYS.COLLECTION.POLICY_VENDORS]: OnyxTypes.PolicyVendors; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS]: OnyxTypes.RecentlyUsedCategories; @@ -1756,6 +1757,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION]: OnyxTypes.SearchTagFiltersPaginationState; + [ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS]: OnyxTypes.SearchTagFilterItem[]; [ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.REPORT_LAST_VISIT_TIMES]: OnyxTypes.ReportLastVisitTimes; diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index feeed02c9ffe..8106d8fef209 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -37,7 +37,7 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti : ''; const {searchResults, isSearching, isLoadingMore, hasMore, loadMore, searchTags, isInitialLoading, searchQuery} = useSearchTagFilters(policyIDs); - const hasSearchResults = !!searchResults && Object.keys(searchResults).length > 0; + const hasSearchResults = !!searchResults && searchResults.length > 0; const shouldUseOfflineFallback = isOffline && !hasSearchResults; const tagItems: Array> = []; @@ -64,18 +64,15 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti addTagName(tagName); } } + tagItems.sort((a, b) => sortOptionsWithEmptyValue(a.text.toString(), b.text.toString(), localeCompare)); } else { - // Preserve backend order - new items append at end for infinite scroll. + // Preserve backend order - backend pre-sorts tags across policies alphabetically. // When offline the API is skipped, so cached results are filtered locally by the search query. - for (const policyTags of Object.values(searchResults ?? {})) { - for (const tag of Object.values(policyTags ?? {})) { - addTagName(tag.tagName); - } + for (const tag of searchResults ?? []) { + addTagName(tag.tagName); } } - tagItems.sort((a, b) => sortOptionsWithEmptyValue(a.text.toString(), b.text.toString(), localeCompare)); - const shouldShowEmptyTagOption = !isSearching && !isInitialLoading && (!searchQuery || emptyTagItem.text.toLowerCase().includes(lowerSearchQuery)); if (shouldShowEmptyTagOption) { diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts index ef5f6abe329b..5d0f90dfbfb8 100644 --- a/src/hooks/useSearchTagFilters.ts +++ b/src/hooks/useSearchTagFilters.ts @@ -5,8 +5,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {OnyxCollection} from 'react-native-onyx'; - import {useEffect, useRef, useState} from 'react'; import useNetwork from './useNetwork'; @@ -16,8 +14,8 @@ import useOnyx from './useOnyx'; * Hook for managing paginated tag filter search. */ type UseSearchTagFiltersResult = { - /** The paginated tag search results from Onyx, keyed by full Onyx key (searchPolicyTags_) */ - searchResults: OnyxCollection; + /** The paginated tag search results from Onyx */ + searchResults: OnyxTypes.SearchTagFilterItem[] | null; /** Whether a new search request is in flight */ isSearching: boolean; @@ -57,7 +55,7 @@ function logRequestFailure(message: string, error: Error) { */ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { const {isOffline} = useNetwork(); - const [searchResults] = useOnyx(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS); + const [searchResults] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS); const [paginationState] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION); const [isSearching, setIsSearching] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -68,7 +66,7 @@ function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { const searchQuery = paginationState?.searchQuery ?? ''; // Track if we have cached data to avoid showing loading state on remount - const hasCachedData = !!searchResults && Object.keys(searchResults).length > 0; + const hasCachedData = !!searchResults && searchResults.length > 0; const [prevPolicyIDs, setPrevPolicyIDs] = useState(policyIDs); const [isFilteringLocally, setIsFilteringLocally] = useState(false); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index e82ca79d503b..fdaba54e5bd7 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1066,9 +1066,9 @@ function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams, should const optimisticData: AnyOnyxUpdate[] = shouldCancelPendingRequests ? [ { - onyxMethod: Onyx.METHOD.SET_COLLECTION, - key: ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, - value: {}, + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, + value: [], }, ] : []; @@ -1099,7 +1099,7 @@ function setSearchTagFiltersPagination(hasMore: boolean, nextCursor: string, sea /** Resets tag filter pagination and cached results when the filter closes. */ function clearSearchTagFiltersState() { setSearchTagFiltersPagination(false, '', ''); - Onyx.setCollection(ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, {}); + Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, []); } function openBulkChangeApproverPage(reportIDList: OpenBulkChangeApproverPageParams['reportIDList']) { diff --git a/src/setup/index.ts b/src/setup/index.ts index b875f4ed48f4..e58adb447df1 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -81,7 +81,7 @@ export default function () { ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, - ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS, + ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE, diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts index 21088d43c032..27b9689190fa 100644 --- a/src/types/onyx/PolicyTag.ts +++ b/src/types/onyx/PolicyTag.ts @@ -76,4 +76,13 @@ type SearchPolicyTag = { /** Record of search policy tags, indexed by ":" */ type SearchPolicyTags = Record; -export type {PolicyTag, PolicyTags, PolicyTagLists, ParticipantsPolicyTags, SearchPolicyTags}; +/** Tag filter result item returned in flat searchTagFiltersResults array */ +type SearchTagFilterItem = { + /** Name of the tag */ + tagName: string; + + /** Name of the tag list */ + tagListName: string; +}; + +export type {PolicyTag, PolicyTags, PolicyTagLists, ParticipantsPolicyTags, SearchPolicyTags, SearchTagFilterItem}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 3888b448b028..ad6e6a7d17b3 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -137,7 +137,7 @@ import type {PolicyEmployeeList} from './PolicyEmployee'; import type PolicyEmployee from './PolicyEmployee'; import type PolicyJoinMember from './PolicyJoinMember'; import type PolicyOwnershipChangeChecks from './PolicyOwnershipChangeChecks'; -import type {ParticipantsPolicyTags, PolicyTag, PolicyTagLists, PolicyTags, SearchPolicyTags} from './PolicyTag'; +import type {ParticipantsPolicyTags, PolicyTag, PolicyTagLists, PolicyTags, SearchPolicyTags, SearchTagFilterItem} from './PolicyTag'; import type PolicyVendors from './PolicyVendor'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; import type PrivatePromoDiscount from './PrivatePromoDiscount'; @@ -318,6 +318,7 @@ export type { PolicyTagLists, ParticipantsPolicyTags, SearchPolicyTags, + SearchTagFilterItem, PrivatePersonalDetails, QuickAction, ShareBankAccount, diff --git a/tests/unit/hooks/useSearchTagFiltersTest.ts b/tests/unit/hooks/useSearchTagFiltersTest.ts index 44474fe685b8..be7d81e5a984 100644 --- a/tests/unit/hooks/useSearchTagFiltersTest.ts +++ b/tests/unit/hooks/useSearchTagFiltersTest.ts @@ -44,11 +44,7 @@ function setPartialTagFilterState(searchQuery: string) { nextCursor: 'cursor-1', searchQuery, }; - onyxData[ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS] = { - [`${ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS}${POLICY_ID}`]: { - tag1: {tagName: `${searchQuery}-match`}, - }, - }; + onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS] = [{tagName: `${searchQuery}-match`, tagListName: 'TagList'}]; } function setCompleteTagFilterState(searchQuery: string) { @@ -57,12 +53,10 @@ function setCompleteTagFilterState(searchQuery: string) { nextCursor: '', searchQuery, }; - onyxData[ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS] = { - [`${ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS}${POLICY_ID}`]: { - tag1: {tagName: `${searchQuery}-match`}, - tag2: {tagName: 'other-tag'}, - }, - }; + onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS] = [ + {tagName: `${searchQuery}-match`, tagListName: 'TagList'}, + {tagName: 'other-tag', tagListName: 'TagList'}, + ]; } describe('useSearchTagFilters', () => { @@ -77,7 +71,7 @@ describe('useSearchTagFilters', () => { onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION] = {hasMore, nextCursor, searchQuery}; }); mockClearSearchTagFiltersState.mockClear().mockImplementation(() => { - delete onyxData[ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS]; + delete onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS]; delete onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION]; }); }); @@ -223,12 +217,10 @@ describe('useSearchTagFilters', () => { nextCursor: '', searchQuery: 'ch', }; - onyxData[ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS] = { - [`${ONYXKEYS.COLLECTION.SEARCH_POLICY_TAGS}${POLICY_ID}`]: { - tag1: {tagName: 'chicago'}, - tag2: {tagName: 'charlotte'}, - }, - }; + onyxData[ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS] = [ + {tagName: 'chicago', tagListName: 'TagList'}, + {tagName: 'charlotte', tagListName: 'TagList'}, + ]; rerender({}); mockOpenSearchTagFiltersPage.mockClear(); From d24ad8dd7861653dd8dc0dc3f47f8f481abe2818 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:25:34 +0530 Subject: [PATCH 152/485] Forward onEndReachedThreshold to tag MultiSelect --- src/components/Search/FilterComponents/MultiSelect.tsx | 5 +++++ src/components/Search/FilterComponents/TagSelector.tsx | 1 + 2 files changed, 6 insertions(+) diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 1458d2e9a4e0..56fbbfab11c5 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -56,6 +56,9 @@ type MultiSelectProps = SearchFilterCommonProps>> & /** Called when the scroll position gets near the end of the list */ onEndReached?: () => void; + /** How far from the end the bottom edge of the list must be to trigger onEndReached */ + onEndReachedThreshold?: number; + /** Called when the debounced search term changes */ onSearchChange?: (searchTerm: string) => void; @@ -81,6 +84,7 @@ function MultiSelect({ footer, onChange, onEndReached, + onEndReachedThreshold, onSearchChange, isLoadingMore, isSearching, @@ -203,6 +207,7 @@ function MultiSelect({ style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} footerContent={footerContent} onEndReached={onEndReached} + onEndReachedThreshold={onEndReachedThreshold} listEmptyContent={shouldShowSearchSpinner ? renderCenteredSpinner() : undefined} shouldShowListEmptyContent={shouldShowSearchSpinner} /> diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index 8106d8fef209..3b2cc07acef3 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -115,6 +115,7 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti footer={footer} onChange={(tags) => onChange(tags.map((tag) => tag.value))} onEndReached={hasMore && !shouldUseOfflineFallback ? loadMore : undefined} + onEndReachedThreshold={0.75} onSearchChange={searchTags} loading={isInitialLoading} isSearching={isSearching} From 15f7a384b8940fe5ec94b5183ececfe29013b936 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:59:28 +0530 Subject: [PATCH 153/485] Append continuation tag filter slices in Onyx --- src/libs/actions/Search.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index fdaba54e5bd7..92b835dadc9f 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -88,6 +88,7 @@ import type { ReportAction, ReportActions, SaveSearch, + SearchTagFilterItem, Transaction, TransactionViolations, } from '@src/types/onyx'; @@ -1051,8 +1052,19 @@ type OpenSearchTagFiltersPageResponse = { /** Pagination cursor to pass for fetching the next page of tag filters */ nextCursor?: string; + + /** Slice of tag filter results for this page */ + tags?: SearchTagFilterItem[]; }; +let currentSearchTagFiltersResults: SearchTagFilterItem[] = []; +Onyx.connect({ + key: ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, + callback: (val) => { + currentSearchTagFiltersResults = val ?? []; + }, +}); + /** * Fetches a page of tag filter search results from the server. * Returns pagination metadata (hasMore, nextCursor) for infinite scroll. @@ -1073,10 +1085,14 @@ function openSearchTagFiltersPage(params: OpenSearchTagFiltersPageParams, should ] : []; - // The response body contains pagination metadata (hasMore, nextCursor) needed immediately to drive tag filter infinite scroll. + // The response body contains pagination metadata (hasMore, nextCursor) and tag slices needed to drive tag filter infinite scroll. return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}, {optimisticData}).then((response) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OpenSearchTagFiltersPage response fields are command-specific and not declared on the shared Response type const tagFiltersResponse = response as OpenSearchTagFiltersPageResponse | undefined; + const newTags = tagFiltersResponse?.tags ?? []; + if (params.cursor && newTags.length > 0) { + Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, [...currentSearchTagFiltersResults, ...newTags]); + } return { hasMore: !!tagFiltersResponse?.hasMore, nextCursor: tagFiltersResponse?.nextCursor ?? '', From 30b17b7038c4065c126c565e3b5af3317859dca6 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 00:14:50 +0530 Subject: [PATCH 154/485] [CFI] Show export account column on workspace card tables Signed-off-by: krishna2323 --- src/CONST/index.ts | 12 + .../WorkspaceCompanyCardsTableRow.tsx | 21 +- .../WorkspaceCompanyCardsTable/index.tsx | 73 +- .../WorkspaceExpensifyCardsTableRow.tsx | 31 +- .../WorkspaceExpensifyCardsTable/index.tsx | 68 +- src/languages/en.ts | 1 + src/pages/workspace/companyCards/utils.tsx | 740 ++++++++++-------- .../WorkspaceExpensifyCardListPage.tsx | 16 +- tests/unit/CompanyCardExportTest.ts | 672 +++++++++++++++- 9 files changed, 1290 insertions(+), 344 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4067968dcbb2..9cbfed2802ea 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5137,6 +5137,15 @@ const CONST = { }, CARD_LIST_THRESHOLD: 8, DEFAULT_EXPORT_TYPE: 'default', + + /** + * How a card's export account is resolved. Most integrations point a card's NVP at one entry in a flat account + * list, while Rillet and DualEntry resolve it through a program account that each card feed can override. + */ + EXPORT_RESOLVER: { + SINGLE_ACCOUNT: 'singleAccount', + PROGRAM_ACCOUNT: 'programAccount', + }, EXPORT_CARD_TYPES: { /** * Name of Card NVP for QBO custom export accounts @@ -8750,6 +8759,9 @@ const CONST = { /** How narrow a free-text column may be squeezed before the table scrolls instead, matching the ~180px default text column width table libraries use. */ MIN_FREE_TEXT_COLUMN_WIDTH: 180, + + /** Export account names are unbounded, so this column truncates rather than widening the table past its container. */ + MAX_EXPORT_ACCOUNT_COLUMN_WIDTH: 240, }, }, diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx index 1c640201cc49..7702fe413d0b 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx @@ -31,6 +31,7 @@ type WorkspaceCompanyCardTableRowData = TableData & isCardDeleted: boolean; isAssigned: boolean; assignedCard?: Card; + exportAccountTitle?: string; onDismissError?: () => void; }; @@ -45,6 +46,9 @@ type WorkspaceCompanyCardTableRowProps = { /** Whether the current member can edit company cards */ canWriteCompanyCards: boolean; + /** Whether the Export account column is shown, so the row must keep its cell in step with the column */ + shouldShowExportAccountColumn: boolean; + shouldUseNarrowTableLayout: boolean; rowIndex: number; @@ -61,6 +65,7 @@ function WorkspaceCompanyCardTableRow({ feedName, CardFeedIcon, shouldUseNarrowTableLayout, + shouldShowExportAccountColumn, rowIndex, isAssigningCardDisabled, canWriteCompanyCards, @@ -111,7 +116,7 @@ function WorkspaceCompanyCardTableRow({ return Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute(feedName, cardID.toString()))); }; - const accessibilityLabel = [memberColumnTitle, formattedCardDetails, formattedCustomCardName].filter(Boolean).join(', '); + const accessibilityLabel = [memberColumnTitle, formattedCardDetails, formattedCustomCardName, item.exportAccountTitle].filter(Boolean).join(', '); return ( )} + {!shouldUseNarrowTableLayout && shouldShowExportAccountColumn && ( + + + + )} + void; @@ -114,6 +121,8 @@ function WorkspaceCompanyCardsTable({ // again rather than being suppressed forever. const isFeedConnectionBroken = feedName ? cardFeedErrors[feedName]?.shouldPromptBrokenConnection : false; + const policy = usePolicy(policyID); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); const [selectedCardKeys, setSelectedCardKeys] = useState([]); @@ -182,22 +191,62 @@ function WorkspaceCompanyCardsTable({ const shouldShowGBDisclaimer = isGB && (isNoFeed || hasNoAssignedCard); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; - const columns: Array> = [ + // Mirrors the Accounting section's own eligibility check on the card details page, so the column follows the same + // rules as that section rather than introducing a second set of them. + const syncingAccountingIntegration = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.find((integration) => integration === connectionSyncProgress?.connectionName); + const connectedIntegration = getConnectedIntegration(policy, CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES) ?? syncingAccountingIntegration; + const cardExportSettings = getPolicyCardExportSettings(connectedIntegration, policyID, translate, policy); + const shouldShowExportAccountColumn = !!cardExportSettings?.shouldShowMenuItem; + + const columns: Array> = [ { key: 'member', label: translate('common.member'), sortable: true, + dynamicSizing: { + // The cell stacks the member's name above the card details or login, so whichever renders wider decides + // the column's width. + getContentToMeasure: (item) => [ + { + text: item.isAssigned ? (item.cardholder?.displayName ?? item.cardholder?.login ?? '') : translate('workspace.moreFeatures.companyCards.unassignedCards'), + fontSize: fontScale.text, + }, + {text: item.customCardName ?? '', fontSize: fontScale.label}, + ], + extraWidth: MEMBER_CELL_AVATAR_WIDTH, + }, }, { key: 'card', label: translate('workspace.companyCards.card'), sortable: true, + dynamicSizing: { + // Masked card numbers all render at the same length, so this column always fits them in full. + getContentToMeasure: (item) => [{text: item.cardName, fontSize: fontScale.text}], + shouldFitContent: true, + }, }, { key: 'customCardName', label: translate('workspace.companyCards.cardName'), sortable: true, + dynamicSizing: { + getContentToMeasure: (item) => (item.customCardName ? [{text: item.customCardName, fontSize: fontScale.text}] : []), + }, }, + ...(shouldShowExportAccountColumn + ? [ + { + key: 'exportAccount' as const, + label: translate('workspace.moreFeatures.companyCards.exportAccount'), + sortable: true, + dynamicSizing: { + getContentToMeasure: (item: WorkspaceCompanyCardTableItemData) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), + maxWidth: CONST.TABLES.DYNAMIC_COLUMNS.MAX_EXPORT_ACCOUNT_COLUMN_WIDTH, + }, + }, + ] + : []), { key: 'actions', label: '', @@ -205,6 +254,12 @@ function WorkspaceCompanyCardsTable({ styling: { containerStyles: [styles.justifyContentEnd, styles.pr3], }, + dynamicSizing: { + // Only unassigned rows render the Assign button, so that is the widest content this column ever holds. + getContentToMeasure: () => [{text: translate('workspace.companyCards.assign'), fontSize: fontScale.text, fontWeight: '700'}], + extraWidth: variables.iconSizeNormal + styles.gap3.gap, + shouldFitContent: true, + }, }, ]; @@ -224,6 +279,8 @@ function WorkspaceCompanyCardsTable({ isAssigned, assignedCard, cardholder, + // Unassigned cards have no details page and so no Accounting section to match, hence no title. + exportAccountTitle: assignedCard ? getCardExportAccountTitle(cardExportSettings, assignedCard) : undefined, errors: isFeedConnectionBroken || assignedCard?.pendingFields?.lastScrape ? undefined : assignedCard?.errors, pendingAction: assignedCard?.pendingAction, onDismissError: () => resetFailedWorkspaceCompanyCardUnassignment(domainOrWorkspaceAccountID, bankName, assignedCard?.cardID), @@ -262,7 +319,8 @@ function WorkspaceCompanyCardsTable({ return -1 * orderMultiplier; } - const cardNameSortingResult = localeCompare(a.cardName, b.cardName) * orderMultiplier; + const cardNameComparison = localeCompare(a.cardName, b.cardName); + const cardNameSortingResult = cardNameComparison * orderMultiplier; if (!a.isAssigned && !b.isAssigned) { return cardNameSortingResult; @@ -283,6 +341,13 @@ function WorkspaceCompanyCardsTable({ return localeCompare(a.customCardName ?? '', b.customCardName ?? '') * orderMultiplier; } + if (activeSorting.columnKey === 'exportAccount') { + const exportAccountComparison = localeCompare(a.exportAccountTitle ?? '', b.exportAccountTitle ?? ''); + + // Most cards share the default export account, so ties fall back to the card name for a stable order. + return (exportAccountComparison !== 0 ? exportAccountComparison : cardNameComparison) * orderMultiplier; + } + return 0; }; @@ -359,6 +424,7 @@ function WorkspaceCompanyCardsTable({ isAssigningCardDisabled={isAssigningCardDisabled} canWriteCompanyCards={canWriteCompanyCards} shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} + shouldShowExportAccountColumn={shouldShowExportAccountColumn} /> ); @@ -401,6 +467,7 @@ function WorkspaceCompanyCardsTable({ compareItems={compareItems} isItemInSearch={isItemInSearch} isItemInFilter={isItemInFilter} + shouldUseDynamicColumns initialSortColumn="member" selectionEnabled={showTableControls} selectedKeys={validSelectedCardKeys} diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx index bcc23d2dc620..e4686e682cb5 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx @@ -29,9 +29,12 @@ type WorkspaceExpensifyCardsTableRowProps = { item: WorkspaceExpensifyCardTableRowData; rowIndex: number; shouldUseNarrowTableLayout: boolean; + + /** Whether the Export account column is shown, so the row must keep its cell in step with the column */ + shouldShowExportAccountColumn: boolean; }; -export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldUseNarrowTableLayout}: WorkspaceExpensifyCardsTableRowProps) { +export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldUseNarrowTableLayout, shouldShowExportAccountColumn}: WorkspaceExpensifyCardsTableRowProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'FreezeCard']); const styles = useThemeStyles(); const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); @@ -61,7 +64,18 @@ export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldU } } - const accessibilityLabel = [cardholderName, item.name, cardType, limitTypeLabel, item.lastFourPAN, statusLabel, formattedLimit, formattedRemainingLimit, frozenByText] + const accessibilityLabel = [ + cardholderName, + item.name, + cardType, + limitTypeLabel, + item.lastFourPAN, + statusLabel, + item.exportAccountTitle, + formattedLimit, + formattedRemainingLimit, + frozenByText, + ] .filter(Boolean) .join(', '); @@ -185,6 +199,19 @@ export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldU )} + {!shouldUseNarrowTableLayout && shouldShowExportAccountColumn && ( + + + + )} + > = [ + const columns: Array> = [ { key: 'name', label: translate('workspace.expensifyCard.name'), sortable: true, styling: { // Cardholder names and card titles are the longest values in the table, so this column takes the - // space freed up by giving Type, Last 4 and Status fixed widths. Limit type still needs a full share - // to fit its longest value, so this stops at double rather than taking everything. + // space freed up by giving Type, Last 4 and Status fixed widths. This flex only applies to the static + // fallback layout, since dynamic column widths below resolve to px tracks instead. flex: 2, }, + dynamicSizing: { + // The cell stacks the cardholder's name above the card's own title, so whichever renders wider decides + // the column's width. + getContentToMeasure: (item) => [ + {text: item.cardholder?.displayName ?? item.cardholder?.login ?? '', fontSize: fontScale.text}, + {text: item.name, fontSize: fontScale.label}, + ], + extraWidth: MEMBER_CELL_AVATAR_WIDTH, + }, }, { key: 'type', @@ -128,10 +148,10 @@ export default function WorkspaceExpensifyCardsTable({ key: 'limitType', label: translate('workspace.card.issueNewCard.limitType'), sortable: true, - styling: { - // minWidth: 0 lets the grid track size purely from its 1fr share instead of the cell content, - // so a long limit type value truncates instead of widening the column. - containerStyles: [styles.mnw0], + dynamicSizing: { + getContentToMeasure: (item) => [{text: translate(getTranslationKeyForLimitType(item.limitType)), fontSize: fontScale.text}], + // A limit type is one of a short, known set of labels, so the column always shows them in full. + shouldFitContent: true, }, }, { @@ -149,6 +169,19 @@ export default function WorkspaceExpensifyCardsTable({ containerStyles: [styles.mnw0], }, }, + ...(shouldShowExportAccountColumn + ? [ + { + key: 'exportAccount' as const, + label: translate('workspace.moreFeatures.companyCards.exportAccount'), + sortable: true, + dynamicSizing: { + getContentToMeasure: (item: WorkspaceExpensifyCardTableRowData) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), + maxWidth: CONST.TABLES.DYNAMIC_COLUMNS.MAX_EXPORT_ACCOUNT_COLUMN_WIDTH, + }, + }, + ] + : []), { key: 'limit', label: translate('workspace.expensifyCard.limit'), @@ -156,6 +189,11 @@ export default function WorkspaceExpensifyCardsTable({ styling: { containerStyles: [styles.justifyContentEnd], }, + dynamicSizing: { + getContentToMeasure: (item) => [{text: convertToShortDisplayString(item.limit, item.currency), fontSize: fontScale.text}], + // A truncated currency amount is misleading, so this column always shows it in full. + shouldFitContent: true, + }, }, { key: 'remainingLimit', @@ -164,6 +202,10 @@ export default function WorkspaceExpensifyCardsTable({ styling: { containerStyles: [styles.justifyContentEnd], }, + dynamicSizing: { + getContentToMeasure: (item) => [{text: convertToShortDisplayString(item.remainingLimit, item.currency), fontSize: fontScale.text}], + shouldFitContent: true, + }, }, { key: 'actions', @@ -208,6 +250,14 @@ export default function WorkspaceExpensifyCardsTable({ return (item1.remainingLimit - item2.remainingLimit) * orderMultiplier; } + if (activeSorting.columnKey === 'exportAccount') { + const exportAccountComparison = localeCompare(item1.exportAccountTitle ?? '', item2.exportAccountTitle ?? ''); + + if (exportAccountComparison !== 0) { + return exportAccountComparison * orderMultiplier; + } + } + const cardholderName1 = item1.cardholder?.displayName ?? item1.cardholder?.login ?? ''; const cardholderName2 = item2.cardholder?.displayName ?? item2.cardholder?.login ?? ''; return localeCompare(cardholderName1, cardholderName2) * orderMultiplier; @@ -220,6 +270,7 @@ export default function WorkspaceExpensifyCardsTable({ item={item} rowIndex={index} shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} + shouldShowExportAccountColumn={shouldShowExportAccountColumn} /> ); @@ -248,6 +299,7 @@ export default function WorkspaceExpensifyCardsTable({ renderItem={renderCardItem} compareItems={compareItems} isItemInSearch={isItemInSearch} + shouldUseDynamicColumns initialSortColumn="name" narrowLayoutSortColumn="name" title={translate('workspace.common.expensifyCard')} diff --git a/src/languages/en.ts b/src/languages/en.ts index 3de4476a3e5d..556ba31e502f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6604,6 +6604,7 @@ const translations = { neverUpdated: 'Never', noAccountsFound: 'No accounts found', defaultCard: 'Default card', + exportAccount: 'Export account', downgradeTitle: `Can't downgrade workspace`, downgradeSubTitle: `This workspace can't be downgraded because multiple card feeds are connected (excluding Expensify Cards). Please keep only one card feed to proceed.`, noAccountsFoundDescription: (connection: string) => `Please add the account in ${connection} and sync the connection again`, diff --git a/src/pages/workspace/companyCards/utils.tsx b/src/pages/workspace/companyCards/utils.tsx index 643991e9e025..b965b5416d89 100644 --- a/src/pages/workspace/companyCards/utils.tsx +++ b/src/pages/workspace/companyCards/utils.tsx @@ -16,7 +16,8 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Card, CompanyCardFeedWithDomainID, Policy} from '@src/types/onyx'; -import type {Account, PolicyConnectionName} from '@src/types/onyx/Policy'; +import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; +import type {PolicyConnectionName} from '@src/types/onyx/Policy'; import type {ValueOf} from 'type-fest'; @@ -31,55 +32,186 @@ type ExportIntegration = { shouldShowMenuItem?: boolean; }; -function getExportMenuItem( +/** An export account candidate, normalized so every integration shares one resolution path. */ +type ExportAccountOption = { + /** Value stored in the card's export NVP, and the option's value */ + id: string; + + /** Text shown for this account */ + label: string; + + /** Option key, which QBO and QBD key by label because they stored display names before they stored ids */ + keyForList: string; +}; + +/** A Rillet or DualEntry account, which the connection config can point at by GL code rather than by id. */ +type ProgramAccountOption = ExportAccountOption & { + /** Value the connection config stores to reference this account: the GL code for Rillet, the id for DualEntry */ + configKey: string; + + /** Whether the account is offered in the option list */ + isSelectable: boolean; +}; + +/** One NVP resolved against one flat candidate list. Used by QBO, Xero, NetSuite, Sage Intacct and QBD. */ +type SingleAccountExport = { + type: typeof CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT; + + /** The card NVP holding this integration's export account */ + nvpKey: ValueOf; + + accounts: ExportAccountOption[]; + + /** Shown when the card follows the workspace default */ + defaultLabel: string; + + /** The workspace's own export account, only reachable when the card has no NVP of its own */ + workspaceDefaultAccountID?: string; + + /** Whether a missed id match should be retried against the account labels */ + shouldFallBackToLabelMatch?: boolean; +}; + +/** One NVP resolved against a program account that each card feed can override. Used by Rillet and DualEntry. */ +type ProgramAccountExport = { + type: typeof CONST.COMPANY_CARDS.EXPORT_RESOLVER.PROGRAM_ACCOUNT; + + /** The card NVP holding this integration's export account */ + nvpKey: typeof CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT | typeof CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT; + + /** Every account, in connection order, because an account that is no longer offered can still be the resolved one */ + accounts: ProgramAccountOption[]; + + /** Config key of the workspace wide program account */ + workspaceProgramAccountKey?: string; + + /** Program account overrides, per card feed */ + programAccountKeysByFeed?: Record; + + /** Prefixes the resolved title */ + exportsToLabel: string; + + /** Marks the workspace default inside the title, already lower cased */ + defaultTitleSuffix: string; + + /** Marks the workspace default in the option list */ + defaultOptionPrefix: string; +}; + +/** + * Everything about a workspace's card export that does not depend on an individual card. Resolve this once per + * workspace, then resolve each card's title against it. + */ +type CardExportSettings = { + description?: string; + exportPageLink?: string; + exportType?: ValueOf; + shouldShowMenuItem?: boolean; + shouldHideMenuItemDescription?: boolean; + shouldShowMenuItemIcon?: boolean; + + /** Absent when the connection's export configuration has no per card account to resolve */ + accountSelection?: SingleAccountExport | ProgramAccountExport; +}; + +/** One card's resolved export account. */ +type CardExportAccountSelection = { + title?: string; + + /** Whether the card follows the workspace default */ + isDefaultTitle: boolean; + + /** Resolved account id, which marks the selected option */ + selectedAccountID?: string; + + /** Program account for this card's feed, which marks the default option */ + programAccountID?: string; +}; + +/** + * Puts an integration's accounts into the shared shape. QBO and QBD keyed their options by display name before ids + * were stored, so those two pass `shouldKeyByLabel`. + */ +function normalizeAccounts(accounts: Array<{id: string; name: string}> | undefined, shouldKeyByLabel = false): ExportAccountOption[] { + return (accounts ?? []).map(({id, name}) => ({id, label: name, keyForList: shouldKeyByLabel ? name : id})); +} + +/** + * Builds the option list for an integration that resolves one NVP against a flat account list, with the workspace + * default prepended. The default entry carries the label in all three of its fields, which is what every integration + * did before they shared this builder. + */ +function buildSingleAccountOptions(accountSelection: SingleAccountExport, selection: CardExportAccountSelection): SelectorType[] { + const options = + accountSelection.accounts.length > 0 + ? [{id: accountSelection.defaultLabel, label: accountSelection.defaultLabel, keyForList: accountSelection.defaultLabel}, ...accountSelection.accounts] + : []; + + return options.map((option) => ({ + value: option.id, + text: option.label, + keyForList: option.keyForList, + isSelected: selection.isDefaultTitle ? option.label === accountSelection.defaultLabel : option.id === selection.selectedAccountID, + })); +} + +/** + * Builds the option list for an integration whose default comes from a program account. The program account's option + * carries an empty value, which is how these two integrations signal "follow the default" to the export account page. + */ +function buildProgramAccountOptions(accountSelection: ProgramAccountExport, selection: CardExportAccountSelection, styles: ThemeStyles): SelectorType[] { + const options = accountSelection.accounts + .filter((account) => account.isSelectable) + .map((account) => ({ + value: selection.programAccountID === account.id ? '' : account.id, + text: `${selection.programAccountID === account.id ? `${accountSelection.defaultOptionPrefix} - ` : ''}${account.label}`, + keyForList: account.id, + isSelected: selection.selectedAccountID === account.id, + })); + + return sortDefaultToTop(options, (option) => selection.programAccountID === option.keyForList, styles); +} + +function buildExportAccountOptions(accountSelection: CardExportSettings['accountSelection'], selection: CardExportAccountSelection, styles: ThemeStyles): SelectorType[] { + if (!accountSelection) { + return []; + } + + return accountSelection.type === CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT + ? buildSingleAccountOptions(accountSelection, selection) + : buildProgramAccountOptions(accountSelection, selection, styles); +} + +/** + * Resolves everything about a workspace's card export that is the same for all of its cards. This is the expensive + * half of the work, so a list of cards resolves it once and then resolves each card's title against the result. + */ +function getPolicyCardExportSettings( connectionName: PolicyConnectionName | undefined, policyID: string, translate: LocaleContextProps['translate'], - styles: ThemeStyles, policy?: Policy, - companyCard?: Card, backTo?: string | undefined, -): ExportIntegration | undefined { +): CardExportSettings | undefined { const basePath = ROUTES.POLICY_ACCOUNTING.getRoute(policyID); const currentConnectionName = getCurrentAccountingIntegrationName(policy, translate); const defaultCard = translate('workspace.moreFeatures.companyCards.defaultCard'); const defaultVendor = translate('workspace.accounting.defaultVendor'); const defaultAccount = translate('workspace.accounting.defaultAccount'); - const defaultMenuItem: Account & {value?: string} = { - name: defaultCard, - value: defaultCard, - id: defaultCard, - currency: '', - }; - - const defaultVendorMenuItem: Account & {value?: string} = { - name: defaultVendor, - value: defaultVendor, - id: defaultVendor, - currency: '', - }; - - const defaultAccountMenuItem: Account & {value?: string} = { - name: defaultAccount, - value: defaultAccount, - id: defaultAccount, - currency: '', - }; - // The default option label matches the export method: a vendor bill exports to a vendor, a journal entry/check to an account, and a card to a card. - const getDefaultExportLabel = (exportDestination: string | undefined): {defaultLabel: string; defaultLabelMenuItem: Account & {value?: string}} => { + const getDefaultExportLabel = (exportDestination: string | undefined): string => { switch (exportDestination) { case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL: case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - return {defaultLabel: defaultVendor, defaultLabelMenuItem: defaultVendorMenuItem}; + return defaultVendor; case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK: - return {defaultLabel: defaultAccount, defaultLabelMenuItem: defaultAccountMenuItem}; + return defaultAccount; default: - return {defaultLabel: defaultCard, defaultLabelMenuItem: defaultMenuItem}; + return defaultCard; } }; @@ -99,81 +231,63 @@ function getExportMenuItem( const typeReimbursable = reimbursableExpensesExportDestination ? translate(`workspace.qbo.accounts.${reimbursableExpensesExportDestination}`) : undefined; const type = typeNonReimbursable ?? typeReimbursable; const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, type) : undefined; - let data: Account[]; - let shouldShowMenuItem = nonReimbursableExpensesExportDestination !== CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL; - let selectedAccount: Account | undefined; - const defaultExportAccount = nonReimbursableExpensesAccount?.name ?? reimbursableExpensesAccount?.name; - let isDefaultTitle = false; - let exportType: ValueOf | undefined; + const exportPageLink = createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path, backTo ?? basePath); + const shouldShowMenuItem = nonReimbursableExpensesExportDestination !== CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL; + const workspaceDefaultAccountID = nonReimbursableExpensesAccount?.name ?? reimbursableExpensesAccount?.name; const qboConfig = nonReimbursableExpensesExportDestination ?? reimbursableExpensesExportDestination; + const defaultLabel = getDefaultExportLabel(qboConfig); + switch (qboConfig) { case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD: { - data = creditCards ?? []; - isDefaultTitle = - !companyCard?.nameValuePairs?.quickbooks_online_export_account || - companyCard?.nameValuePairs?.quickbooks_online_export_account === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE; - selectedAccount = (creditCards ?? []).find((currentCard) => currentCard.id === (companyCard?.nameValuePairs?.quickbooks_online_export_account ?? defaultExportAccount)); - exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT; - break; - } - case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD: { - data = quickbooksOnlineBankAccounts ?? []; - isDefaultTitle = - !companyCard?.nameValuePairs?.quickbooks_online_export_account_debit || - companyCard?.nameValuePairs?.quickbooks_online_export_account_debit === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE; - selectedAccount = (quickbooksOnlineBankAccounts ?? []).find( - (bank) => bank.id === (companyCard?.nameValuePairs?.quickbooks_online_export_account_debit ?? defaultExportAccount), - ); - exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT_DEBIT; - break; - } + case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD: + return { + description, + exportPageLink, + shouldShowMenuItem, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT, + accounts: normalizeAccounts(creditCards, true), + defaultLabel, + workspaceDefaultAccountID, + }, + }; + case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD: + return { + description, + exportPageLink, + shouldShowMenuItem, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT_DEBIT, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT_DEBIT, + accounts: normalizeAccounts(quickbooksOnlineBankAccounts, true), + defaultLabel, + workspaceDefaultAccountID, + }, + }; default: - shouldShowMenuItem = false; - data = []; + return {description, exportPageLink, shouldShowMenuItem: false}; } - const {defaultLabel, defaultLabelMenuItem} = getDefaultExportLabel(qboConfig); - const resultData = data.length > 0 ? [defaultLabelMenuItem, ...data] : data; - - return { - description, - title: isDefaultTitle ? defaultLabel : selectedAccount?.name, - exportType, - shouldShowMenuItem, - exportPageLink: createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path, backTo ?? basePath), - data: resultData.map((card) => ({ - value: card.id, - text: card.name, - keyForList: card.name, - isSelected: isDefaultTitle ? card.name === defaultLabel : card.id === selectedAccount?.id, - })), - }; } case CONST.POLICY.CONNECTIONS.NAME.XERO: { const type = translate('workspace.xero.bankAccount'); const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, type) : undefined; - const exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT; - const defaultExportAccount = exportConfiguration?.nonReimbursableAccount; - const isDefaultTitle = - !companyCard?.nameValuePairs?.xero_export_bank_account || companyCard?.nameValuePairs?.xero_export_bank_account === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE; - const selectedAccount = (bankAccounts ?? []).find((bank) => bank.id === (companyCard?.nameValuePairs?.xero_export_bank_account ?? defaultExportAccount)); - const resultData = (bankAccounts ?? [])?.length > 0 ? [defaultAccountMenuItem, ...(bankAccounts ?? [])] : bankAccounts; return { description, - exportType, shouldShowMenuItem: !!exportConfiguration?.nonReimbursableAccount, - title: isDefaultTitle ? defaultAccount : selectedAccount?.name, - data: (resultData ?? []).map((card) => { - return { - value: card.id, - text: card.name, - keyForList: card.id, - isSelected: isDefaultTitle ? card.name === defaultAccount : selectedAccount?.id === card.id, - }; - }), + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT, + accounts: normalizeAccounts(bankAccounts), + defaultLabel: defaultAccount, + workspaceDefaultAccountID: exportConfiguration?.nonReimbursableAccount, + }, }; } case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: { @@ -184,67 +298,42 @@ function getExportMenuItem( ? translate(`workspace.netsuite.exportDestination.values.${config.reimbursableExpensesExportDestination}.label`) : undefined; const type = typeNonreimbursable ?? typeReimbursable; - let title: string | undefined = ''; - let exportType: ValueOf | undefined; - let shouldShowMenuItem = true; const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, type) : undefined; - let data: SelectorType[]; - let defaultExportAccount: string | undefined = ''; - let isDefaultTitle = false; - + const exportPageLink = createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, backTo ?? basePath); const netSuiteConfig = config?.nonreimbursableExpensesExportDestination ?? config?.reimbursableExpensesExportDestination; switch (netSuiteConfig) { - case CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL: { - const vendors = policy?.connections?.netsuite?.options.data.vendors; - defaultExportAccount = config?.defaultVendor; - isDefaultTitle = !companyCard?.nameValuePairs?.netsuite_export_vendor || companyCard?.nameValuePairs?.netsuite_export_vendor === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE; - const selectedVendor = vendors?.find(({id}) => id === (companyCard?.nameValuePairs?.netsuite_export_vendor ?? defaultExportAccount)); - title = isDefaultTitle ? defaultVendor : selectedVendor?.name; - const resultData = (vendors ?? []).length > 0 ? [defaultVendorMenuItem, ...(vendors ?? [])] : vendors; - data = (resultData ?? []).map(({id, name}) => { - return { - value: id, - text: name, - keyForList: id, - isSelected: isDefaultTitle ? name === defaultVendor : selectedVendor?.id === id, - }; - }); - exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR; - break; - } - case CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY: { - const payableAccounts = policy?.connections?.netsuite?.options.data.payableList; - defaultExportAccount = config?.payableAcct; - isDefaultTitle = - !companyCard?.nameValuePairs?.netsuite_export_payable_account || - companyCard?.nameValuePairs?.netsuite_export_payable_account === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE; - const selectedPayableAccount = payableAccounts?.find(({id}) => id === (companyCard?.nameValuePairs?.netsuite_export_payable_account ?? defaultExportAccount)); - title = isDefaultTitle ? defaultAccount : selectedPayableAccount?.name; - const resultData = (payableAccounts ?? []).length > 0 ? [defaultAccountMenuItem, ...(payableAccounts ?? [])] : payableAccounts; - data = (resultData ?? []).map(({id, name}) => { - return { - value: id, - text: name, - keyForList: id, - isSelected: isDefaultTitle ? name === defaultAccount : selectedPayableAccount?.id === id, - }; - }); - exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_ACCOUNT; - break; - } + case CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL: + return { + description, + exportPageLink, + shouldShowMenuItem: true, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR, + accounts: normalizeAccounts(policy?.connections?.netsuite?.options.data.vendors), + defaultLabel: defaultVendor, + workspaceDefaultAccountID: config?.defaultVendor, + }, + }; + case CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY: + return { + description, + exportPageLink, + shouldShowMenuItem: true, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_ACCOUNT, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_ACCOUNT, + accounts: normalizeAccounts(policy?.connections?.netsuite?.options.data.payableList), + defaultLabel: defaultAccount, + workspaceDefaultAccountID: config?.payableAcct, + }, + }; default: - shouldShowMenuItem = false; - data = []; + return {description, exportPageLink, shouldShowMenuItem: false}; } - return { - description, - title, - shouldShowMenuItem, - exportType, - data, - exportPageLink: createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, backTo ?? basePath), - }; } case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: { const isNonReimbursable = !!exportConfig?.nonReimbursable; @@ -253,77 +342,50 @@ function getExportMenuItem( const typeReimbursable = isReimbursable ? translate(`workspace.sageIntacct.reimbursableExpenses.values.${exportConfig.reimbursable}`) : undefined; const type = typeNonReimbursable ?? typeReimbursable; const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, type) : undefined; - let exportType: ValueOf | undefined; - let title: string | undefined = ''; - let isDefaultTitle = false; - - let shouldShowMenuItem = true; - let data: SelectorType[]; - + const exportPageLink = createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path, backTo ?? basePath); const sageConfig = exportConfig?.nonReimbursable ?? exportConfig?.reimbursable; switch (sageConfig) { case CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL: - case CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL: { - const defaultExportAccount = isNonReimbursable ? getSageIntacctNonReimbursableActiveDefaultVendor(policy) : exportConfig?.reimbursableExpenseReportDefaultVendor; - isDefaultTitle = !!( - companyCard?.nameValuePairs?.intacct_export_vendor === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE || !companyCard?.nameValuePairs?.intacct_export_vendor - ); - const vendors = policy?.connections?.intacct?.data?.vendors ?? []; - const selectedVendorID = companyCard?.nameValuePairs?.intacct_export_vendor ?? defaultExportAccount; - const selectedVendor = (vendors ?? []).find(({id}) => id === selectedVendorID); - title = isDefaultTitle ? defaultVendor : selectedVendor?.value; - const resultData = (vendors ?? []).length > 0 ? [defaultVendorMenuItem, ...(vendors ?? [])] : vendors; - data = (resultData ?? []).map(({id, value}) => { - return { - value: id, - text: value, - keyForList: id, - isSelected: isDefaultTitle ? value === defaultVendor : selectedVendor?.id === id, - }; - }); - exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR; - break; - } + case CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL: + return { + description, + exportPageLink, + shouldShowMenuItem: true, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR, + // Sage Intacct holds a vendor's display text in `value`, so that is the label rather than `name`. + accounts: (policy?.connections?.intacct?.data?.vendors ?? []).map(({id, value}) => ({id, label: value, keyForList: id})), + defaultLabel: defaultVendor, + workspaceDefaultAccountID: isNonReimbursable ? getSageIntacctNonReimbursableActiveDefaultVendor(policy) : exportConfig?.reimbursableExpenseReportDefaultVendor, + }, + }; case CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE: { - const intacctCreditCards = policy?.connections?.intacct?.data?.creditCards ?? []; const activeDefaultVendor = getSageIntacctNonReimbursableActiveDefaultVendor(policy); - const defaultVendorAccount = (policy?.connections?.intacct?.data?.vendors ?? []).find((vendor) => vendor.id === activeDefaultVendor); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const defaultExportAccount = exportConfig?.nonReimbursableAccount || defaultVendorAccount; - isDefaultTitle = !!( - companyCard?.nameValuePairs?.intacct_export_charge_card === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE || !companyCard?.nameValuePairs?.intacct_export_charge_card - ); - const selectedVendorID = companyCard?.nameValuePairs?.intacct_export_charge_card ?? defaultExportAccount; - const selectedCard = (intacctCreditCards ?? []).find(({id}) => id === selectedVendorID); - title = isDefaultTitle ? defaultCard : selectedCard?.name; - const resultData = (intacctCreditCards ?? []).length > 0 ? [defaultMenuItem, ...(intacctCreditCards ?? [])] : intacctCreditCards; - - data = (resultData ?? []).map(({id, name}) => { - return { - value: id, - text: name, - keyForList: id, - isSelected: isDefaultTitle ? name === defaultCard : selectedCard?.id === id, - }; - }); - exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_CHARGE_CARD; - break; + + return { + description, + exportPageLink, + shouldShowMenuItem: true, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_CHARGE_CARD, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_CHARGE_CARD, + accounts: normalizeAccounts(policy?.connections?.intacct?.data?.creditCards), + defaultLabel: defaultCard, + // A card with no export NVP of its own is titled from `defaultLabel`, so this id is never + // looked up. It is kept for parity with the other integrations. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + workspaceDefaultAccountID: exportConfig?.nonReimbursableAccount || defaultVendorAccount?.id, + }, + }; } default: - shouldShowMenuItem = false; - data = []; + return {description, exportPageLink, shouldShowMenuItem: false}; } - - return { - description, - shouldShowMenuItem, - exportType, - title, - exportPageLink: createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path, backTo ?? ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), - data, - }; } case CONST.POLICY.CONNECTIONS.NAME.QBD: { const nonReimbursableExpenses = exportQBD?.nonReimbursable; @@ -332,160 +394,189 @@ function getExportMenuItem( const typeReimbursable = reimbursableExpenses ? translate(`workspace.qbd.accounts.${reimbursableExpenses}`) : undefined; const type = typeNonReimbursable ?? typeReimbursable; const description = currentConnectionName && type ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, type) : undefined; - let data: Account[]; - let shouldShowMenuItem = + const exportPageLink = createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path, backTo ?? basePath); + const shouldShowMenuItem = nonReimbursableExpenses !== CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK && nonReimbursableExpenses !== CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL; - let title: string | undefined = ''; - let selectedAccount: Account | undefined; - let isDefaultTitle = false; - let exportType: ValueOf | undefined; const qbdConfig = nonReimbursableExpenses ?? reimbursableExpenses; - const {defaultLabel, defaultLabelMenuItem} = getDefaultExportLabel(qbdConfig); + switch (qbdConfig) { case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK: case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - case CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD: { - data = creditCardAccounts ?? []; - selectedAccount = - (creditCardAccounts ?? []).find((account) => account.id === companyCard?.nameValuePairs?.quickbooks_desktop_export_account_credit) ?? - (creditCardAccounts ?? []).find((account) => account.name === companyCard?.nameValuePairs?.quickbooks_desktop_export_account_credit); - isDefaultTitle = - companyCard?.nameValuePairs?.quickbooks_desktop_export_account_credit === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE || - !companyCard?.nameValuePairs?.quickbooks_desktop_export_account_credit; - title = isDefaultTitle ? defaultLabel : selectedAccount?.name; - exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_DESKTOP_EXPORT_ACCOUNT_CREDIT; - break; - } + case CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD: + return { + description, + exportPageLink, + shouldShowMenuItem, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_DESKTOP_EXPORT_ACCOUNT_CREDIT, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_DESKTOP_EXPORT_ACCOUNT_CREDIT, + accounts: normalizeAccounts(creditCardAccounts, true), + defaultLabel: getDefaultExportLabel(qbdConfig), + // Classic saved an account id in this NVP while NewDot used to save the display name, so a + // missed id match is retried against the labels. + shouldFallBackToLabelMatch: true, + }, + }; default: - shouldShowMenuItem = false; - data = []; + return {description, exportPageLink, shouldShowMenuItem: false}; } - - const resultData = data.length > 0 ? [defaultLabelMenuItem, ...data] : data; - - return { - description, - title, - exportType, - shouldShowMenuItem, - exportPageLink: createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path, backTo ?? basePath), - data: resultData.map((card) => ({ - value: card.id, - text: card.name, - keyForList: card.name, - isSelected: isDefaultTitle ? card.name === defaultLabel : card.id === selectedAccount?.id, - })), - }; } case CONST.POLICY.CONNECTIONS.NAME.RILLET: { const rilletConfig = policy?.connections?.rillet?.config; const rilletData = policy?.connections?.rillet?.data; - const exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT; const exportReimbursable = rilletConfig?.export?.reimbursable ?? CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL; const exportNonReimbursable = rilletConfig?.export?.nonReimbursable ?? CONST.RILLET_EXPORT_NON_REIMBURSABLE.CREDIT_CARD_CHARGE; - const shouldShowMenuItem = - rilletConfig?.export?.exportToMultipleAccounts && - exportReimbursable === CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL && - exportNonReimbursable === CONST.RILLET_EXPORT_NON_REIMBURSABLE.CREDIT_CARD_CHARGE; - const creditCardAccountCode = rilletConfig?.export?.creditCardAccountCode; - const cardProgramsUsingCustomAccounts = rilletConfig?.export?.cardProgramAccounts; - const cardProgramAccountCode = (companyCard?.bank ? cardProgramsUsingCustomAccounts?.[companyCard.bank] : undefined) ?? creditCardAccountCode; - const cardProgramAccount = rilletData?.accounts?.find((account) => account.code === cardProgramAccountCode); - const isUsingCustomAccount = companyCard?.nameValuePairs && CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT in companyCard.nameValuePairs; - const cardAccountID = - (companyCard?.nameValuePairs && CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT in companyCard.nameValuePairs - ? companyCard.nameValuePairs[CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT] - : undefined) ?? cardProgramAccount?.id; - const cardAccount = rilletData?.accounts?.find((account) => account.id === cardAccountID); - const cardAccountDisplayName = cardAccount ? `${cardAccount.code} ${cardAccount.name}${isUsingCustomAccount ? '' : ` (${translate('common.default').toLocaleLowerCase()})`}` : ''; - const title = `${translate('common.exportsTo')} ${cardAccountDisplayName}`; - const description = currentConnectionName - ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, translate('workspace.rillet.cardAccount.label')) - : undefined; - const filteredUnprocessedData = - rilletData?.accounts - ?.filter( - (accountItem) => - accountItem.type === CONST.RILLET_ACCOUNT_TYPE.LIABILITY && - accountItem.subtype === CONST.RILLET_ACCOUNT_SUBTYPE.CREDIT_CARD && - accountItem.status === CONST.RILLET_ACCOUNT_STATUS.ACTIVE, - ) - .map((accountItem) => ({ - value: cardProgramAccount?.id === accountItem.id ? '' : accountItem.id, - text: `${cardProgramAccount?.id === accountItem.id ? `${translate('common.default')} - ` : ''}${accountItem.code} ${accountItem.name}`, - keyForList: accountItem.id, - isSelected: cardAccountID === accountItem.id, - })) ?? []; - const filteredData = sortDefaultToTop(filteredUnprocessedData, (accountItem) => cardProgramAccount?.id === accountItem.keyForList, styles); return { - title, - description, - exportType, + description: currentConnectionName + ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, translate('workspace.rillet.cardAccount.label')) + : undefined, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, shouldHideMenuItemDescription: true, shouldShowMenuItemIcon: true, - shouldShowMenuItem, - data: filteredData, + shouldShowMenuItem: + rilletConfig?.export?.exportToMultipleAccounts && + exportReimbursable === CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL && + exportNonReimbursable === CONST.RILLET_EXPORT_NON_REIMBURSABLE.CREDIT_CARD_CHARGE, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.PROGRAM_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, + accounts: (rilletData?.accounts ?? []).map((account) => ({ + id: account.id, + label: `${account.code} ${account.name}`, + keyForList: account.id, + configKey: account.code, + isSelectable: + account.type === CONST.RILLET_ACCOUNT_TYPE.LIABILITY && + account.subtype === CONST.RILLET_ACCOUNT_SUBTYPE.CREDIT_CARD && + account.status === CONST.RILLET_ACCOUNT_STATUS.ACTIVE, + })), + workspaceProgramAccountKey: rilletConfig?.export?.creditCardAccountCode, + programAccountKeysByFeed: rilletConfig?.export?.cardProgramAccounts, + exportsToLabel: translate('common.exportsTo'), + defaultTitleSuffix: translate('common.default').toLocaleLowerCase(), + defaultOptionPrefix: translate('common.default'), + }, }; } - case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: { const dualentryConfig = policy?.connections?.dualEntry?.config; const dualentryData = policy?.connections?.dualEntry?.data; - const exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT; const exportReimbursable = dualentryConfig?.export?.reimbursable ?? CONST.DUALENTRY_EXPORT_REIMBURSABLE.VENDOR_BILL; const exportNonReimbursable = dualentryConfig?.export?.nonReimbursable ?? CONST.DUALENTRY_EXPORT_NON_REIMBURSABLE.DIRECT_EXPENSE; - const shouldShowMenuItem = - dualentryConfig?.export?.exportToMultipleAccounts && - exportReimbursable === CONST.DUALENTRY_EXPORT_REIMBURSABLE.VENDOR_BILL && - exportNonReimbursable === CONST.DUALENTRY_EXPORT_NON_REIMBURSABLE.DIRECT_EXPENSE; - const creditCardAccountID = dualentryConfig?.export?.creditCardAccountID; - const cardProgramsUsingCustomAccounts = dualentryConfig?.export?.cardProgramAccounts; - const cardProgramAccountID = (companyCard?.bank ? cardProgramsUsingCustomAccounts?.[companyCard.bank] : undefined) ?? creditCardAccountID; - const cardProgramAccount = dualentryData?.accounts?.find((account) => account.id === cardProgramAccountID); - const isUsingCustomAccount = companyCard?.nameValuePairs && CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT in companyCard.nameValuePairs; - const cardAccountID = - (companyCard?.nameValuePairs && CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT in companyCard.nameValuePairs - ? companyCard.nameValuePairs[CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT] - : undefined) ?? cardProgramAccount?.id; - const cardAccount = dualentryData?.accounts?.find((account) => account.id === cardAccountID); - const cardAccountDisplayName = cardAccount ? `${cardAccount.id} ${cardAccount.name}${isUsingCustomAccount ? '' : ` (${translate('common.default').toLocaleLowerCase()})`}` : ''; - const title = `${translate('common.exportsTo')} ${cardAccountDisplayName}`; - const description = currentConnectionName - ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, translate('workspace.dualEntry.cardAccount.label')) - : undefined; - const filteredUnprocessedData = - dualentryData?.accounts - ?.filter( - (accountItem) => - accountItem.isActive && (accountItem.accountType === CONST.DUALENTRY_ACCOUNT_TYPE.CREDIT_CARD || accountItem.accountType === CONST.DUALENTRY_ACCOUNT_TYPE.BANK), - ) - .map((accountItem) => ({ - value: cardProgramAccount?.id === accountItem.id ? '' : accountItem.id, - text: `${cardProgramAccount?.id === accountItem.id ? `${translate('common.default')} - ` : ''}${accountItem.id} ${accountItem.name}`, - keyForList: accountItem.id, - isSelected: cardAccountID === accountItem.id, - })) ?? []; - const filteredData = sortDefaultToTop(filteredUnprocessedData, (accountItem) => cardProgramAccount?.id === accountItem.keyForList, styles); return { - title, - description, - exportType, + description: currentConnectionName + ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, translate('workspace.dualEntry.cardAccount.label')) + : undefined, + exportType: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, shouldHideMenuItemDescription: true, shouldShowMenuItemIcon: true, - shouldShowMenuItem, - data: filteredData, + shouldShowMenuItem: + dualentryConfig?.export?.exportToMultipleAccounts && + exportReimbursable === CONST.DUALENTRY_EXPORT_REIMBURSABLE.VENDOR_BILL && + exportNonReimbursable === CONST.DUALENTRY_EXPORT_NON_REIMBURSABLE.DIRECT_EXPENSE, + accountSelection: { + type: CONST.COMPANY_CARDS.EXPORT_RESOLVER.PROGRAM_ACCOUNT, + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, + accounts: (dualentryData?.accounts ?? []).map((account) => ({ + id: account.id, + label: `${account.id} ${account.name}`, + keyForList: account.id, + // DualEntry's config references a program account by id, where Rillet's references it by GL code. + configKey: account.id, + isSelectable: account.isActive && (account.accountType === CONST.DUALENTRY_ACCOUNT_TYPE.CREDIT_CARD || account.accountType === CONST.DUALENTRY_ACCOUNT_TYPE.BANK), + })), + workspaceProgramAccountKey: dualentryConfig?.export?.creditCardAccountID, + programAccountKeysByFeed: dualentryConfig?.export?.cardProgramAccounts, + exportsToLabel: translate('common.exportsTo'), + defaultTitleSuffix: translate('common.default').toLocaleLowerCase(), + defaultOptionPrefix: translate('common.default'), + }, }; } - default: return undefined; } } +/** + * Resolves one card's export account against its workspace's export settings. This is the cheap half of the work: a + * couple of lookups and no allocation, so a table can call it per row. + */ +function getCardExportAccountSelection(settings: CardExportSettings | undefined, companyCard: Card | undefined): CardExportAccountSelection { + const accountSelection = settings?.accountSelection; + + if (!accountSelection) { + return {isDefaultTitle: false}; + } + + if (accountSelection.type === CONST.COMPANY_CARDS.EXPORT_RESOLVER.SINGLE_ACCOUNT) { + const nvpValue = companyCard?.nameValuePairs?.[accountSelection.nvpKey]; + const isDefaultTitle = !nvpValue || nvpValue === CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE; + const candidateID = nvpValue ?? accountSelection.workspaceDefaultAccountID; + const selectedAccount = + accountSelection.accounts.find((account) => account.id === candidateID) ?? + (accountSelection.shouldFallBackToLabelMatch ? accountSelection.accounts.find((account) => account.label === candidateID) : undefined); + + return { + isDefaultTitle, + title: isDefaultTitle ? accountSelection.defaultLabel : selectedAccount?.label, + selectedAccountID: selectedAccount?.id, + }; + } + + const programAccountKey = (companyCard?.bank ? accountSelection.programAccountKeysByFeed?.[companyCard.bank] : undefined) ?? accountSelection.workspaceProgramAccountKey; + const programAccount = accountSelection.accounts.find((account) => account.configKey === programAccountKey); + const isUsingCustomAccount = !!companyCard?.nameValuePairs && accountSelection.nvpKey in companyCard.nameValuePairs; + const selectedAccountID = (isUsingCustomAccount ? companyCard?.nameValuePairs?.[accountSelection.nvpKey] : undefined) ?? programAccount?.id; + const selectedAccount = accountSelection.accounts.find((account) => account.id === selectedAccountID); + const accountDisplayName = selectedAccount ? `${selectedAccount.label}${isUsingCustomAccount ? '' : ` (${accountSelection.defaultTitleSuffix})`}` : ''; + + return { + isDefaultTitle: !isUsingCustomAccount, + title: `${accountSelection.exportsToLabel} ${accountDisplayName}`, + selectedAccountID, + programAccountID: programAccount?.id, + }; +} + +/** + * The accounting export account a card is mapped to, which is the value the card details page shows in its Accounting + * section. + */ +function getCardExportAccountTitle(settings: CardExportSettings | undefined, companyCard: Card | undefined): string | undefined { + return getCardExportAccountSelection(settings, companyCard).title; +} + +function getExportMenuItem( + connectionName: PolicyConnectionName | undefined, + policyID: string, + translate: LocaleContextProps['translate'], + styles: ThemeStyles, + policy?: Policy, + companyCard?: Card, + backTo?: string | undefined, +): ExportIntegration | undefined { + const settings = getPolicyCardExportSettings(connectionName, policyID, translate, policy, backTo); + + if (!settings) { + return undefined; + } + + const {accountSelection, ...menuItem} = settings; + const selection = getCardExportAccountSelection(settings, companyCard); + + return { + ...menuItem, + title: selection.title, + data: buildExportAccountOptions(accountSelection, selection, styles), + }; +} + /** * Builds a back path to company card details using Members when the matching details * entry has accountID, otherwise Company Cards, so goBack can match the stack entry. @@ -526,4 +617,5 @@ function getCompanyCardDetailsBackPath( return createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute(feed, cardID), detailsBasePath); } -export {getCompanyCardDetailsBackPath, getExportMenuItem}; +export {getCardExportAccountTitle, getCompanyCardDetailsBackPath, getExportMenuItem, getPolicyCardExportSettings}; +export type {CardExportSettings}; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 4d966977f529..bce974427993 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -36,11 +36,13 @@ import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUt import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import {getConnectedIntegration, getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; +import {getCardExportAccountTitle, getPolicyCardExportSettings} from '@pages/workspace/companyCards/utils'; + import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -79,6 +81,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [domains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const settings = getCardSettings(cardSettings); const {allFeeds: allAdminExpensifyCardFeeds} = useExpensifyCardFeedsForFeedSelector(policyID); const shouldShowSelector = allAdminExpensifyCardFeeds.length >= 1; @@ -105,6 +108,13 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp /> ); + // Mirrors the Accounting section's own eligibility check on the card details page, so the column follows the same + // rules as that section rather than introducing a second set of them. + const syncingAccountingIntegration = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.find((integration) => integration === connectionSyncProgress?.connectionName); + const connectedIntegration = getConnectedIntegration(policy, CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES) ?? syncingAccountingIntegration; + const cardExportSettings = getPolicyCardExportSettings(connectedIntegration, policyID, translate, policy); + const shouldShowExportAccountColumn = !!cardExportSettings?.shouldShowMenuItem; + const settlementCurrency = useCurrencyForExpensifyCard({policyID, fundID}); const shouldShowEuUkDisclaimer = isCurrencySupportedForECards(settlementCurrency); const allCards = useMemo(() => { @@ -149,6 +159,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp currency: settlementCurrency, isVirtual: !!card.nameValuePairs?.isVirtual, limitType: card.nameValuePairs?.limitType, + exportAccountTitle: getCardExportAccountTitle(cardExportSettings, card), frozenByDisplayName, frozenByAccountID: card.nameValuePairs?.frozen?.byAccountID, frozenDate: card.nameValuePairs?.frozen?.date, @@ -158,7 +169,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp onClose: () => clearDeletePaymentMethodError(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, card.cardID), }; }), - [allCards, defaultFundID, personalDetails, settlementCurrency, translate, formatPhoneNumber], + [allCards, cardExportSettings, defaultFundID, personalDetails, settlementCurrency, translate, formatPhoneNumber], ); const bulkExportOptions: Array> = [ @@ -359,6 +370,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp onRowSelectionChange={setSelectedCardKeys} cardSettings={cardSettings} cardSettingsBase={settings} + shouldShowExportAccountColumn={shouldShowExportAccountColumn} personalDetails={personalDetails} listFooterComponent={disclaimerFooter} listFooterComponentStyle={[styles.flexGrow1, styles.justifyContentEnd]} diff --git a/tests/unit/CompanyCardExportTest.ts b/tests/unit/CompanyCardExportTest.ts index e9f52b421d91..9831813c9c33 100644 --- a/tests/unit/CompanyCardExportTest.ts +++ b/tests/unit/CompanyCardExportTest.ts @@ -10,8 +10,11 @@ import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Card, CompanyCardFeedWithDomainID, Policy} from '@src/types/onyx'; +import type {QBONonReimbursableExportAccountType, QBOReimbursableExportAccountType} from '@src/types/onyx/Policy'; -import {getCompanyCardDetailsBackPath, getExportMenuItem} from '../../src/pages/workspace/companyCards/utils'; +import type {PartialDeep, ValueOf} from 'type-fest'; + +import {getCardExportAccountTitle, getCompanyCardDetailsBackPath, getExportMenuItem, getPolicyCardExportSettings} from '../../src/pages/workspace/companyCards/utils'; import createMock from '../utils/createMock'; import {translateLocal} from '../utils/TestHelper'; @@ -94,10 +97,11 @@ function createCard(nvpExportAccount?: string): Card { }); } -describe('getExportMenuItem - QBD credit card account resolution', () => { - const translate: LocaleContextProps['translate'] = translateLocal; - const themeStyles = createMock({}); +const translate: LocaleContextProps['translate'] = translateLocal; +const themeStyles = createMock({}); +const basePath = ROUTES.POLICY_ACCOUNTING.getRoute(MOCK_POLICY_ID); +describe('getExportMenuItem - QBD credit card account resolution', () => { it('resolves account by ID when NVP contains a QBD ListID (Classic-saved)', () => { const policy = createQBDPolicy(); const card = createCard('80000103-1746639410'); @@ -186,6 +190,666 @@ describe('getExportMenuItem - QBD credit card account resolution', () => { }); }); +const QBO_CREDIT_CARDS = [ + {id: 'qbo-cc-1', name: 'Amex Corporate', currency: 'USD'}, + {id: 'qbo-cc-2', name: 'Visa Platinum', currency: 'USD'}, +]; + +const QBO_BANK_ACCOUNTS = [ + {id: 'qbo-bank-1', name: 'Checking 1234', currency: 'USD'}, + {id: 'qbo-bank-2', name: 'Savings 5678', currency: 'USD'}, +]; + +const XERO_BANK_ACCOUNTS = [ + {id: 'xero-bank-1', name: 'Business Account', currency: 'USD'}, + {id: 'xero-bank-2', name: 'Petty Cash', currency: 'USD'}, +]; + +const NETSUITE_VENDORS = [ + {id: 'ns-vendor-1', name: 'Acme Supplies'}, + {id: 'ns-vendor-2', name: 'Globex Rentals'}, +]; + +const NETSUITE_PAYABLE_ACCOUNTS = [ + {id: 'ns-payable-1', name: 'Accounts Payable'}, + {id: 'ns-payable-2', name: 'Corporate Card Payable'}, +]; + +const INTACCT_VENDORS = [ + {id: 'intacct-vendor-1', name: 'intacct-vendor-1', value: 'Acme Vendor'}, + {id: 'intacct-vendor-2', name: 'intacct-vendor-2', value: 'Umbrella Vendor'}, +]; + +const INTACCT_CREDIT_CARDS = [ + {id: 'intacct-card-1', name: 'Intacct Amex'}, + {id: 'intacct-card-2', name: 'Intacct Visa'}, +]; + +const RILLET_ACCOUNTS = [ + { + id: 'rillet-1', + code: '2100', + name: 'Amex Payable', + type: CONST.RILLET_ACCOUNT_TYPE.LIABILITY, + subtype: CONST.RILLET_ACCOUNT_SUBTYPE.CREDIT_CARD, + status: CONST.RILLET_ACCOUNT_STATUS.ACTIVE, + }, + { + id: 'rillet-2', + code: '2200', + name: 'Visa Payable', + type: CONST.RILLET_ACCOUNT_TYPE.LIABILITY, + subtype: CONST.RILLET_ACCOUNT_SUBTYPE.CREDIT_CARD, + status: CONST.RILLET_ACCOUNT_STATUS.ACTIVE, + }, + { + id: 'rillet-3', + code: '2300', + name: 'Retired Card', + type: CONST.RILLET_ACCOUNT_TYPE.LIABILITY, + subtype: CONST.RILLET_ACCOUNT_SUBTYPE.CREDIT_CARD, + status: CONST.RILLET_ACCOUNT_STATUS.INACTIVE, + }, + { + id: 'rillet-4', + code: '6000', + name: 'Office Expense', + type: CONST.RILLET_ACCOUNT_TYPE.EXPENSE, + subtype: 'Office', + status: CONST.RILLET_ACCOUNT_STATUS.ACTIVE, + }, +]; + +const DUALENTRY_ACCOUNTS = [ + {id: 'de-1', number: '2100', name: 'Amex Payable', accountType: CONST.DUALENTRY_ACCOUNT_TYPE.CREDIT_CARD, isActive: true}, + {id: 'de-2', number: '2200', name: 'Visa Payable', accountType: CONST.DUALENTRY_ACCOUNT_TYPE.CREDIT_CARD, isActive: true}, + {id: 'de-3', number: '2300', name: 'Retired Card', accountType: CONST.DUALENTRY_ACCOUNT_TYPE.CREDIT_CARD, isActive: false}, + {id: 'de-4', number: '6000', name: 'Office Expense', accountType: CONST.DUALENTRY_ACCOUNT_TYPE.EXPENSE, isActive: true}, +]; + +function createBasePolicy(connections: PartialDeep): Policy { + return createMock({ + id: MOCK_POLICY_ID, + name: 'Test Policy', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'test@export.com', + ownerAccountID: 1, + outputCurrency: 'USD', + connections, + }); +} + +/** + * Builds a card carrying a single export NVP, so one helper covers every integration's NVP key. Passing `undefined` + * leaves the NVP off entirely, which is what an unconfigured card looks like. + */ +function createCardWithExportNVP(nvpKey: ValueOf, nvpValue?: string, bank?: Card['bank']): Card { + const nameValuePairs: Partial> = {}; + if (nvpValue !== undefined) { + nameValuePairs[nvpKey] = nvpValue; + } + + return createMock({ + cardID: 2002, + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + bank: bank ?? CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, + domainName: 'test.exfy', + fraud: 'none', + lastUpdated: '', + nameValuePairs, + }); +} + +describe('getExportMenuItem - QBO', () => { + function createQBOPolicy(nonReimbursableExpensesExportDestination: QBONonReimbursableExportAccountType, reimbursableExpensesExportDestination?: QBOReimbursableExportAccountType) { + return createBasePolicy({ + quickbooksOnline: { + config: { + nonReimbursableExpensesExportDestination, + reimbursableExpensesExportDestination, + nonReimbursableExpensesAccount: {id: 'qbo-cc-2', name: 'Visa Platinum', currency: 'USD'}, + }, + data: { + creditCards: QBO_CREDIT_CARDS, + bankAccounts: QBO_BANK_ACCOUNTS, + }, + }, + }); + } + + it('resolves a credit card export against the credit card list', () => { + const policy = createQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT, 'qbo-cc-1'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.QBO, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe('Amex Corporate'); + expect(result?.shouldShowMenuItem).toBe(true); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT); + expect(result?.exportPageLink).toBe(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path, basePath)); + expect(result?.data).toHaveLength(QBO_CREDIT_CARDS.length + 1); + + const selectedOption = result?.data?.find((item) => item.isSelected); + expect(selectedOption?.value).toBe('qbo-cc-1'); + expect(selectedOption?.text).toBe('Amex Corporate'); + expect(selectedOption?.keyForList).toBe('Amex Corporate'); + }); + + it('resolves a debit card export against the bank account list and its own NVP', () => { + const policy = createQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT_DEBIT, 'qbo-bank-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.QBO, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe('Savings 5678'); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT_DEBIT); + expect(result?.data).toHaveLength(QBO_BANK_ACCOUNTS.length + 1); + }); + + it('hides the menu item for a vendor bill export', () => { + const policy = createQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT, 'qbo-cc-1'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.QBO, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.shouldShowMenuItem).toBe(false); + }); + + it.each([CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK])( + 'labels the default option as an account for a %s reimbursable export', + (reimbursableExpensesExportDestination) => { + const policy = createBasePolicy({ + quickbooksOnline: { + config: {reimbursableExpensesExportDestination}, + data: {creditCards: QBO_CREDIT_CARDS}, + }, + }); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.QBO, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(translateLocal('workspace.accounting.defaultAccount')); + }, + ); + + it('returns no options for an unrecognized export destination', () => { + const policy = createBasePolicy({ + quickbooksOnline: { + data: {creditCards: QBO_CREDIT_CARDS}, + }, + }); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.QBO, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.shouldShowMenuItem).toBe(false); + expect(result?.data).toEqual([]); + }); +}); + +describe('getExportMenuItem - Xero', () => { + function createXeroPolicy(nonReimbursableAccount: string) { + return createBasePolicy({ + xero: { + config: {export: {nonReimbursableAccount}}, + data: {bankAccounts: XERO_BANK_ACCOUNTS}, + }, + }); + } + + it('resolves the bank account named by the NVP and keys options by id', () => { + const policy = createXeroPolicy('xero-bank-1'); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT, 'xero-bank-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.XERO, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe('Petty Cash'); + expect(result?.shouldShowMenuItem).toBe(true); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT); + expect(result?.data).toHaveLength(XERO_BANK_ACCOUNTS.length + 1); + + const selectedOption = result?.data?.find((item) => item.isSelected); + expect(selectedOption?.value).toBe('xero-bank-2'); + expect(selectedOption?.keyForList).toBe('xero-bank-2'); + }); + + it.each([undefined, CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE])('shows the default account label when the NVP is %s', (nvpValue) => { + const policy = createXeroPolicy('xero-bank-1'); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT, nvpValue); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.XERO, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(translateLocal('workspace.accounting.defaultAccount')); + }); + + it('hides the menu item when the workspace has no non-reimbursable account', () => { + const policy = createXeroPolicy(''); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT, 'xero-bank-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.XERO, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.shouldShowMenuItem).toBe(false); + }); +}); + +describe('getExportMenuItem - NetSuite', () => { + function createNetSuitePolicy(nonreimbursableExpensesExportDestination: ValueOf) { + return createBasePolicy({ + netsuite: { + options: { + config: { + nonreimbursableExpensesExportDestination, + defaultVendor: 'ns-vendor-1', + payableAcct: 'ns-payable-1', + }, + data: { + vendors: NETSUITE_VENDORS, + payableList: NETSUITE_PAYABLE_ACCOUNTS, + }, + }, + }, + }); + } + + it('resolves a vendor bill export against the vendor list', () => { + const policy = createNetSuitePolicy(CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR, 'ns-vendor-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.NETSUITE, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe('Globex Rentals'); + expect(result?.shouldShowMenuItem).toBe(true); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR); + expect(result?.exportPageLink).toBe(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, basePath)); + expect(result?.data).toHaveLength(NETSUITE_VENDORS.length + 1); + }); + + it('shows the default vendor label for a vendor bill export with no NVP', () => { + const policy = createNetSuitePolicy(CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.NETSUITE, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(translateLocal('workspace.accounting.defaultVendor')); + }); + + it('resolves a journal entry export against the payable account list', () => { + const policy = createNetSuitePolicy(CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_ACCOUNT, 'ns-payable-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.NETSUITE, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe('Corporate Card Payable'); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_ACCOUNT); + expect(result?.data).toHaveLength(NETSUITE_PAYABLE_ACCOUNTS.length + 1); + }); + + it('hides the menu item for an expense report export', () => { + const policy = createNetSuitePolicy(CONST.NETSUITE_EXPORT_DESTINATION.EXPENSE_REPORT); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR, 'ns-vendor-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.NETSUITE, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.shouldShowMenuItem).toBe(false); + expect(result?.data).toEqual([]); + }); +}); + +describe('getExportMenuItem - Sage Intacct', () => { + it("titles a vendor bill export from the vendor's value rather than its name", () => { + const policy = createBasePolicy({ + intacct: { + config: { + export: { + nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL, + nonReimbursableVendor: 'intacct-vendor-1', + }, + }, + data: {vendors: INTACCT_VENDORS}, + }, + }); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR, 'intacct-vendor-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe('Umbrella Vendor'); + expect(result?.shouldShowMenuItem).toBe(true); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR); + expect(result?.exportPageLink).toBe(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path, basePath)); + expect(result?.data).toHaveLength(INTACCT_VENDORS.length + 1); + + const selectedOption = result?.data?.find((item) => item.isSelected); + expect(selectedOption?.value).toBe('intacct-vendor-2'); + expect(selectedOption?.text).toBe('Umbrella Vendor'); + }); + + it('falls back to the reimbursable export type when no non-reimbursable one is set', () => { + const policy = createBasePolicy({ + intacct: { + config: { + export: { + reimbursable: CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL, + reimbursableExpenseReportDefaultVendor: 'intacct-vendor-1', + }, + }, + data: {vendors: INTACCT_VENDORS}, + }, + }); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR, 'intacct-vendor-1'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe('Acme Vendor'); + expect(result?.shouldShowMenuItem).toBe(true); + }); + + it("titles a credit card charge export from the card's name", () => { + const policy = createBasePolicy({ + intacct: { + config: { + export: { + nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE, + nonReimbursableAccount: 'intacct-card-1', + }, + }, + data: {vendors: INTACCT_VENDORS, creditCards: INTACCT_CREDIT_CARDS}, + }, + }); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_CHARGE_CARD, 'intacct-card-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe('Intacct Visa'); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_CHARGE_CARD); + expect(result?.data).toHaveLength(INTACCT_CREDIT_CARDS.length + 1); + }); + + it('hides the menu item for an expense report export', () => { + const policy = createBasePolicy({ + intacct: { + config: {export: {reimbursable: CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.EXPENSE_REPORT}}, + data: {vendors: INTACCT_VENDORS}, + }, + }); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.shouldShowMenuItem).toBe(false); + expect(result?.data).toEqual([]); + }); +}); + +describe('getExportMenuItem - Rillet', () => { + const exportsTo = translateLocal('common.exportsTo'); + const defaultSuffix = translateLocal('common.default').toLocaleLowerCase(); + + function createRilletPolicy(exportOverrides?: {exportToMultipleAccounts?: boolean; cardProgramAccounts?: Record}) { + return createBasePolicy({ + rillet: { + config: { + export: { + exportToMultipleAccounts: exportOverrides?.exportToMultipleAccounts ?? true, + reimbursable: CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL, + nonReimbursable: CONST.RILLET_EXPORT_NON_REIMBURSABLE.CREDIT_CARD_CHARGE, + creditCardAccountCode: '2100', + cardProgramAccounts: exportOverrides?.cardProgramAccounts, + }, + }, + data: {accounts: RILLET_ACCOUNTS}, + }, + }); + } + + it('marks the workspace program account as the default in the title', () => { + const policy = createRilletPolicy(); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.RILLET, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(`${exportsTo} 2100 Amex Payable (${defaultSuffix})`); + expect(result?.shouldShowMenuItem).toBe(true); + expect(result?.shouldHideMenuItemDescription).toBe(true); + expect(result?.shouldShowMenuItemIcon).toBe(true); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT); + }); + + it('drops the default suffix when the card names its own account', () => { + const policy = createRilletPolicy(); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, 'rillet-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.RILLET, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(`${exportsTo} 2200 Visa Payable`); + }); + + it("uses the card feed's program account override", () => { + const policy = createRilletPolicy({cardProgramAccounts: {[CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: '2200'}}); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, undefined, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.RILLET, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(`${exportsTo} 2200 Visa Payable (${defaultSuffix})`); + + const defaultOption = result?.data?.at(0); + expect(defaultOption?.keyForList).toBe('rillet-2'); + expect(defaultOption?.value).toBe(''); + expect(defaultOption?.text).toBe(`${translateLocal('common.default')} - 2200 Visa Payable`); + }); + + it('offers only active credit card accounts while still resolving an inactive one as the title', () => { + const policy = createRilletPolicy(); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, 'rillet-3'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.RILLET, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(`${exportsTo} 2300 Retired Card`); + expect(result?.data?.map((item) => item.keyForList)).toEqual(['rillet-1', 'rillet-2']); + }); + + it('hides the menu item when the workspace does not export to multiple accounts', () => { + const policy = createRilletPolicy({exportToMultipleAccounts: false}); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.RILLET, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.shouldShowMenuItem).toBeFalsy(); + }); +}); + +describe('getExportMenuItem - DualEntry', () => { + const exportsTo = translateLocal('common.exportsTo'); + const defaultSuffix = translateLocal('common.default').toLocaleLowerCase(); + + function createDualEntryPolicy(exportOverrides?: {exportToMultipleAccounts?: boolean; cardProgramAccounts?: Record}) { + return createBasePolicy({ + dualEntry: { + config: { + export: { + exportToMultipleAccounts: exportOverrides?.exportToMultipleAccounts ?? true, + reimbursable: CONST.DUALENTRY_EXPORT_REIMBURSABLE.VENDOR_BILL, + nonReimbursable: CONST.DUALENTRY_EXPORT_NON_REIMBURSABLE.DIRECT_EXPENSE, + creditCardAccountID: 'de-1', + cardProgramAccounts: exportOverrides?.cardProgramAccounts, + }, + }, + data: {accounts: DUALENTRY_ACCOUNTS}, + }, + }); + } + + it('prefixes the title with the account id and marks the workspace default', () => { + const policy = createDualEntryPolicy(); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(`${exportsTo} de-1 Amex Payable (${defaultSuffix})`); + expect(result?.shouldShowMenuItem).toBe(true); + expect(result?.exportType).toBe(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT); + }); + + it("matches the program account by id and uses the card feed's override", () => { + const policy = createDualEntryPolicy({cardProgramAccounts: {[CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: 'de-2'}}); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, undefined, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(`${exportsTo} de-2 Visa Payable (${defaultSuffix})`); + }); + + it('offers only active credit card and bank accounts', () => { + const policy = createDualEntryPolicy(); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, 'de-2'); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.title).toBe(`${exportsTo} de-2 Visa Payable`); + expect(result?.data?.map((item) => item.keyForList)).toEqual(['de-1', 'de-2']); + }); + + it('hides the menu item when the workspace does not export to multiple accounts', () => { + const policy = createDualEntryPolicy({exportToMultipleAccounts: false}); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT); + + const result = getExportMenuItem(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(result?.shouldShowMenuItem).toBeFalsy(); + }); +}); + +describe('getExportMenuItem - unsupported connections', () => { + it.each([CONST.POLICY.CONNECTIONS.NAME.CERTINIA, undefined])('returns undefined for %s', (connectionName) => { + const policy = createBasePolicy({}); + const card = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_FINANCIALFORCE_EXPORT_VENDOR); + + expect(getExportMenuItem(connectionName, MOCK_POLICY_ID, translate, themeStyles, policy, card)).toBeUndefined(); + }); +}); + +describe('getPolicyCardExportSettings + getCardExportAccountTitle', () => { + const connectionsToTest = [ + { + name: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: createBasePolicy({ + quickbooksOnline: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {creditCards: QBO_CREDIT_CARDS}, + }, + }), + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT, + matchingAccountID: 'qbo-cc-1', + }, + { + name: CONST.POLICY.CONNECTIONS.NAME.XERO, + policy: createBasePolicy({xero: {config: {export: {nonReimbursableAccount: 'xero-bank-1'}}, data: {bankAccounts: XERO_BANK_ACCOUNTS}}}), + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT, + matchingAccountID: 'xero-bank-2', + }, + { + name: CONST.POLICY.CONNECTIONS.NAME.NETSUITE, + policy: createBasePolicy({ + netsuite: {options: {config: {nonreimbursableExpensesExportDestination: CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL}, data: {vendors: NETSUITE_VENDORS}}}, + }), + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR, + matchingAccountID: 'ns-vendor-2', + }, + { + name: CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, + policy: createBasePolicy({ + intacct: {config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL}}, data: {vendors: INTACCT_VENDORS}}, + }), + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR, + matchingAccountID: 'intacct-vendor-2', + }, + { + name: CONST.POLICY.CONNECTIONS.NAME.QBD, + policy: createQBDPolicy(), + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_DESKTOP_EXPORT_ACCOUNT_CREDIT, + matchingAccountID: '80000104-1746639411', + }, + { + name: CONST.POLICY.CONNECTIONS.NAME.RILLET, + policy: createBasePolicy({ + rillet: { + config: { + export: { + exportToMultipleAccounts: true, + reimbursable: CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL, + nonReimbursable: CONST.RILLET_EXPORT_NON_REIMBURSABLE.CREDIT_CARD_CHARGE, + creditCardAccountCode: '2100', + }, + }, + data: {accounts: RILLET_ACCOUNTS}, + }, + }), + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, + matchingAccountID: 'rillet-2', + }, + { + name: CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, + policy: createBasePolicy({ + dualEntry: { + config: { + export: { + exportToMultipleAccounts: true, + reimbursable: CONST.DUALENTRY_EXPORT_REIMBURSABLE.VENDOR_BILL, + nonReimbursable: CONST.DUALENTRY_EXPORT_NON_REIMBURSABLE.DIRECT_EXPENSE, + creditCardAccountID: 'de-1', + }, + }, + data: {accounts: DUALENTRY_ACCOUNTS}, + }, + }), + nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, + matchingAccountID: 'de-2', + }, + ] as const; + + it.each( + connectionsToTest.flatMap(({name, policy, nvpKey, matchingAccountID}) => [ + {name, policy, card: createCardWithExportNVP(nvpKey, matchingAccountID), label: `${name} - matched NVP`}, + {name, policy, card: createCardWithExportNVP(nvpKey), label: `${name} - unset NVP (default)`}, + {name, policy, card: createCardWithExportNVP(nvpKey, CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE), label: `${name} - DEFAULT_EXPORT_TYPE`}, + ]), + )('$label: getCardExportAccountTitle matches getExportMenuItem().title', ({name, policy, card}) => { + const settings = getPolicyCardExportSettings(name, MOCK_POLICY_ID, translate, policy); + const legacyResult = getExportMenuItem(name, MOCK_POLICY_ID, translate, themeStyles, policy, card); + + expect(getCardExportAccountTitle(settings, card)).toBe(legacyResult?.title); + expect(settings?.shouldShowMenuItem).toBe(legacyResult?.shouldShowMenuItem); + }); + + it('resolves a different title per card feed when a single settings object is reused across Rillet cards', () => { + const policy = createBasePolicy({ + rillet: { + config: { + export: { + exportToMultipleAccounts: true, + reimbursable: CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL, + nonReimbursable: CONST.RILLET_EXPORT_NON_REIMBURSABLE.CREDIT_CARD_CHARGE, + creditCardAccountCode: '2100', + cardProgramAccounts: {[CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: '2200'}, + }, + }, + data: {accounts: RILLET_ACCOUNTS}, + }, + }); + const settings = getPolicyCardExportSettings(CONST.POLICY.CONNECTIONS.NAME.RILLET, MOCK_POLICY_ID, translate, policy); + + const visaCard = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, undefined, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA); + const otherFeedCard = createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, undefined, CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD); + + expect(getCardExportAccountTitle(settings, visaCard)).toContain('2200 Visa Payable'); + expect(getCardExportAccountTitle(settings, otherFeedCard)).toContain('2100 Amex Payable'); + }); + + it('returns undefined when there is no export settings for the card', () => { + expect(getCardExportAccountTitle(undefined, createCardWithExportNVP(CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT))).toBeUndefined(); + }); +}); + describe('getCompanyCardDetailsBackPath', () => { it('uses Members base when the matching details route has accountID', () => { const state = createSettingsState([ From 39fb9ff120478744d7ed0efbaada67b26c3292fc Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 00:22:55 +0530 Subject: [PATCH 155/485] add tests. Signed-off-by: krishna2323 --- ...aceCompanyCardsExportAccountColumnTest.tsx | 308 ++++++++++++++++++ ...eExpensifyCardsExportAccountColumnTest.tsx | 161 +++++++++ 2 files changed, 469 insertions(+) create mode 100644 tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx create mode 100644 tests/ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx diff --git a/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx b/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx new file mode 100644 index 000000000000..e59b5df94160 --- /dev/null +++ b/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx @@ -0,0 +1,308 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {CompareItemsCallback, TableColumn} from '@components/Table'; +import WorkspaceCompanyCardsTable from '@components/Tables/WorkspaceCompanyCardsTable'; +import type {WorkspaceCompanyCardTableItemData} from '@components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow'; + +import type {UseCompanyCardsResult} from '@hooks/useCompanyCards'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card} from '@src/types/onyx'; +import type {CompanyCardFeedWithDomainID, CompanyCardFeedWithNumber} from '@src/types/onyx/CardFeeds'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +TestHelper.setupApp(); + +const POLICY_ID = 'policy123'; +const DOMAIN_OR_WORKSPACE_ACCOUNT_ID = 11111111; +const FEED_NAME = `${CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE}#${DOMAIN_OR_WORKSPACE_ACCOUNT_ID}` as CompanyCardFeedWithDomainID; +const BANK_NAME = CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE as CompanyCardFeedWithNumber; +const ACCOUNT_ID = 1; +const QBO_EXPORT_ACCOUNT_NVP = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT; + +let capturedColumns: Array> = []; +let capturedData: WorkspaceCompanyCardTableItemData[] = []; +let capturedCompareItems: CompareItemsCallback | undefined; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyIllustrations: () => ({}), + useMemoizedLazyExpensifyIcons: () => ({}), +})); + +jest.mock('@hooks/useCardFeedErrors', () => ({ + __esModule: true, + default: () => ({cardFeedErrors: {}}), +})); + +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: false}), +})); + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({shouldUseNarrowLayout: false, isMediumScreenWidth: false}), +})); + +jest.mock('@components/ActivityIndicator', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return () => ; +}); + +jest.mock('@pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return () => ; +}); + +type MockTableProps = { + children?: React.ReactNode; + columns: Array>; + data: WorkspaceCompanyCardTableItemData[]; + compareItems: CompareItemsCallback; +}; + +// The real Table drives a FlashList and its own text measurement, neither of which this test needs: it only checks +// what WorkspaceCompanyCardsTable computes and hands to Table, so the mock captures those props instead of rendering +// the grid. +jest.mock('@components/Table', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + + function MockTable({children, columns, data, compareItems}: MockTableProps) { + capturedColumns = columns; + capturedData = data; + capturedCompareItems = compareItems; + return {children}; + } + + MockTable.FilterBar = () => ; + MockTable.Header = () => ; + MockTable.ListHeader = ({children}: {children?: React.ReactNode}) => children ?? null; + MockTable.Body = () => ; + MockTable.EmptyState = () => ; + MockTable.NoResultsState = () => ; + MockTable.LoadingState = () => ; + return { + __esModule: true, + default: MockTable, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + composeTableListHeader: jest.requireActual('@components/Table/composeTableListHeader').default, + }; +}); + +jest.mock('@components/CardFeedIcon', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return () => ; +}); + +jest.mock('@components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return () => ; +}); + +function buildAssignedCard(overrides?: Partial): Card { + return createMock({ + cardID: 555, + accountID: ACCOUNT_ID, + bank: BANK_NAME, + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + domainName: 'test.exfy', + fraud: 'none', + lastUpdated: '', + nameValuePairs: {}, + ...overrides, + }); +} + +function buildCompanyCards(assignedCard?: Card): UseCompanyCardsResult { + return createMock({ + feedName: FEED_NAME, + bankName: BANK_NAME, + assignedCards: assignedCard ? {[assignedCard.cardID]: assignedCard} : {}, + companyCardEntries: [ + { + cardName: '4321', + encryptedCardNumber: 'enc-4321', + isAssigned: !!assignedCard, + assignedCard, + }, + ], + workspaceCardFeedsStatus: {[DOMAIN_OR_WORKSPACE_ACCOUNT_ID]: {isLoading: false}}, + selectedFeed: {feed: BANK_NAME, status: {}}, + isInitiallyLoadingFeeds: false, + isNoFeed: false, + isFeedPending: false, + isFeedAdded: true, + onyxMetadata: { + cardListMetadata: {status: 'loaded'}, + allCardFeedsMetadata: {status: 'loaded'}, + lastSelectedFeedMetadata: {status: 'loaded'}, + }, + }); +} + +function renderTable(companyCards: UseCompanyCardsResult) { + return render( + + + , + ); +} + +async function setQBOPolicy(nvpExportAccount?: string) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + id: POLICY_ID, + connections: { + quickbooksOnline: { + config: { + nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + }, + data: { + creditCards: [{id: 'qbo-cc-1', name: 'Amex Corporate', currency: 'USD'}], + }, + }, + }, + }); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [ACCOUNT_ID]: {accountID: ACCOUNT_ID, displayName: 'Alice Smith', login: 'alice@example.com'}, + }); + + return buildAssignedCard({nameValuePairs: nvpExportAccount === undefined ? {} : {[QBO_EXPORT_ACCOUNT_NVP]: nvpExportAccount}}); +} + +describe('WorkspaceCompanyCardsTable Export account column', () => { + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('shows the column and the resolved title when the connection is eligible', async () => { + const card = await setQBOPolicy('qbo-cc-1'); + renderTable(buildCompanyCards(card)); + await waitForBatchedUpdates(); + + expect(screen.getByTestId('WorkspaceCompanyCardsTable')).toBeTruthy(); + expect(capturedColumns.map((column) => column.key)).toContain('exportAccount'); + expect(capturedData.at(0)?.exportAccountTitle).toBe('Amex Corporate'); + }); + + it('hides the column when the details page would hide the Accounting section too', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + id: POLICY_ID, + connections: { + quickbooksOnline: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, + data: {creditCards: []}, + }, + }, + }); + renderTable(buildCompanyCards(buildAssignedCard())); + await waitForBatchedUpdates(); + + expect(capturedColumns.map((column) => column.key)).not.toContain('exportAccount'); + }); + + it('hides the column when no accounting connection exists', async () => { + renderTable(buildCompanyCards(buildAssignedCard())); + await waitForBatchedUpdates(); + + expect(capturedColumns.map((column) => column.key)).not.toContain('exportAccount'); + }); + + it('shows the default label when the card has no export NVP', async () => { + const card = await setQBOPolicy(undefined); + renderTable(buildCompanyCards(card)); + await waitForBatchedUpdates(); + + expect(capturedData.at(0)?.exportAccountTitle).toBe(TestHelper.translateLocal('workspace.moreFeatures.companyCards.defaultCard')); + }); + + it('renders an empty export cell for an unassigned card without crashing', async () => { + await setQBOPolicy('qbo-cc-1'); + renderTable(buildCompanyCards(undefined)); + await waitForBatchedUpdates(); + + expect(capturedData.at(0)?.isAssigned).toBe(false); + expect(capturedData.at(0)?.exportAccountTitle).toBeUndefined(); + }); + + it('sorts by export account title, falling back to the card name on ties', async () => { + await setQBOPolicy('qbo-cc-1'); + const cardA = buildAssignedCard({cardID: 1, nameValuePairs: {[QBO_EXPORT_ACCOUNT_NVP]: 'qbo-cc-1'}}); + const cardB = buildAssignedCard({cardID: 2, nameValuePairs: {[QBO_EXPORT_ACCOUNT_NVP]: CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE}}); + renderTable(buildCompanyCards(cardA)); + await waitForBatchedUpdates(); + + expect(capturedCompareItems).toBeDefined(); + + const rowA = createMock({ + keyForList: 'a', + cardName: '1111', + isAssigned: true, + isCardDeleted: false, + assignedCard: cardA, + exportAccountTitle: 'Amex Corporate', + }); + const rowB = createMock({ + keyForList: 'b', + cardName: '2222', + isAssigned: true, + isCardDeleted: false, + assignedCard: cardB, + exportAccountTitle: TestHelper.translateLocal('workspace.moreFeatures.companyCards.defaultCard'), + }); + + const ascending = capturedCompareItems?.(rowA, rowB, {columnKey: 'exportAccount', order: CONST.SEARCH.SORT_ORDER.ASC}); + expect(ascending).toBeLessThan(0); + + const descending = capturedCompareItems?.(rowA, rowB, {columnKey: 'exportAccount', order: CONST.SEARCH.SORT_ORDER.DESC}); + expect(descending).toBeGreaterThan(0); + + // Two rows sharing the default export account fall back to the card name for a stable order. + const rowC = createMock({...rowB, keyForList: 'c', cardName: '0000'}); + const tieBreak = capturedCompareItems?.(rowB, rowC, {columnKey: 'exportAccount', order: CONST.SEARCH.SORT_ORDER.ASC}); + expect(tieBreak).toBeGreaterThan(0); + }); + + it('keeps rendering when the export column disappears mid-session', async () => { + const card = await setQBOPolicy('qbo-cc-1'); + renderTable(buildCompanyCards(card)); + await waitForBatchedUpdates(); + + expect(capturedColumns.map((column) => column.key)).toContain('exportAccount'); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + connections: {quickbooksOnline: {config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}}}, + }); + await waitForBatchedUpdates(); + + expect(capturedColumns.map((column) => column.key)).not.toContain('exportAccount'); + expect(screen.getByTestId('WorkspaceCompanyCardsTable')).toBeTruthy(); + }); +}); diff --git a/tests/ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx b/tests/ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx new file mode 100644 index 000000000000..63b07c89ec04 --- /dev/null +++ b/tests/ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx @@ -0,0 +1,161 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {CompareItemsCallback, TableColumn} from '@components/Table'; +import type {WorkspaceExpensifyCardTableRowData} from '@components/Tables/WorkspaceExpensifyCardsTable'; +import WorkspaceExpensifyCardsTable from '@components/Tables/WorkspaceExpensifyCardsTable'; + +import CONST from '@src/CONST'; +import type {Card} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +TestHelper.setupApp(); + +let capturedColumns: Array> = []; +let capturedCompareItems: CompareItemsCallback | undefined; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({}), +})); + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({shouldUseNarrowLayout: false, isMediumScreenWidth: false}), +})); + +type MockTableProps = { + children?: React.ReactNode; + columns: Array>; + compareItems: CompareItemsCallback; +}; + +// The real Table drives a FlashList and its own text measurement, neither of which this test needs: it only checks +// the columns and comparator WorkspaceExpensifyCardsTable computes, so the mock captures those props instead of +// rendering the grid. +jest.mock('@components/Table', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + + function MockTable({children, columns, compareItems}: MockTableProps) { + capturedColumns = columns; + capturedCompareItems = compareItems; + return {children}; + } + + MockTable.FilterBar = () => ; + MockTable.Header = () => ; + MockTable.ListHeader = ({children}: {children?: React.ReactNode}) => children ?? null; + MockTable.Body = () => ; + MockTable.NoResultsState = () => ; + return { + __esModule: true, + default: MockTable, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + composeTableListHeader: jest.requireActual('@components/Table/composeTableListHeader').default, + }; +}); + +jest.mock('@pages/workspace/expensifyCard/WorkspaceCardListLabels', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return () => ; +}); + +function buildCard(overrides?: Partial): Card { + return createMock({ + cardID: 999, + accountID: 1, + bank: CONST.EXPENSIFY_CARD.BANK, + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + domainName: 'test.exfy', + fraud: 'none', + lastUpdated: '', + availableSpend: 10000, + nameValuePairs: {cardTitle: 'Ops card', unapprovedExpenseLimit: 100000, limitType: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY}, + ...overrides, + }); +} + +function buildRow(overrides?: Partial): WorkspaceExpensifyCardTableRowData { + const card = overrides?.card ?? buildCard(); + + return createMock({ + keyForList: String(card.cardID), + cardID: card.cardID, + card, + lastFourPAN: '1234', + name: card.nameValuePairs?.cardTitle ?? '', + cardholder: {accountID: 1, displayName: 'Alice Smith', login: 'alice@example.com'}, + limit: card.nameValuePairs?.unapprovedExpenseLimit ?? 0, + remainingLimit: card.availableSpend ?? 0, + currency: 'USD', + isVirtual: false, + limitType: card.nameValuePairs?.limitType, + action: jest.fn(), + onClose: jest.fn(), + ...overrides, + }); +} + +function renderTable(cards: WorkspaceExpensifyCardTableRowData[], shouldShowExportAccountColumn: boolean) { + return render( + + + , + ); +} + +describe('WorkspaceExpensifyCardsTable Export account column', () => { + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('shows the column when the caller says the connection is eligible', async () => { + renderTable([buildRow({exportAccountTitle: 'Amex Corporate'})], true); + await waitForBatchedUpdates(); + + expect(screen.getByTestId('WorkspaceExpensifyCardsTable')).toBeTruthy(); + expect(capturedColumns.map((column) => column.key)).toContain('exportAccount'); + }); + + it('hides the column when the caller says the connection is not eligible', async () => { + renderTable([buildRow()], false); + await waitForBatchedUpdates(); + + expect(capturedColumns.map((column) => column.key)).not.toContain('exportAccount'); + }); + + it('sorts by export account title, falling back to the cardholder name on ties', async () => { + renderTable([buildRow()], true); + await waitForBatchedUpdates(); + + expect(capturedCompareItems).toBeDefined(); + + const rowA = buildRow({cardID: 1, cardholder: {accountID: 1, displayName: 'Zed', login: 'zed@example.com'}, exportAccountTitle: 'Amex Corporate'}); + const rowB = buildRow({cardID: 2, cardholder: {accountID: 2, displayName: 'Ann', login: 'ann@example.com'}, exportAccountTitle: 'Default card'}); + + const ascending = capturedCompareItems?.(rowA, rowB, {columnKey: 'exportAccount', order: CONST.SEARCH.SORT_ORDER.ASC}); + expect(ascending).toBeLessThan(0); + + // Ties on export account title fall through to the cardholder name comparison. + const rowC = buildRow({cardID: 3, cardholder: {accountID: 3, displayName: 'Ann', login: 'ann2@example.com'}, exportAccountTitle: 'Amex Corporate'}); + const tieBreak = capturedCompareItems?.(rowA, rowC, {columnKey: 'exportAccount', order: CONST.SEARCH.SORT_ORDER.ASC}); + expect(tieBreak).toBeGreaterThan(0); + }); +}); From 6aeeefa241d132f54e0d79603e4bef5a19c856ec Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 00:42:13 +0530 Subject: [PATCH 156/485] Fix export account column to follow established table conventions Signed-off-by: krishna2323 --- .../WorkspaceCompanyCardsTable/index.tsx | 20 ++++++++----------- .../WorkspaceExpensifyCardsTable/index.tsx | 5 +++-- src/styles/variables.ts | 1 + 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index 845c36dfbf4e..01d5c52037de 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -18,7 +18,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {resetFailedWorkspaceCompanyCardUnassignment} from '@libs/actions/CompanyCards'; -import {getCompanyCardCustomName, getDefaultCardName} from '@libs/CardUtils'; +import {formatMaskedCardName, getCompanyCardCustomName, getDefaultCardName} from '@libs/CardUtils'; import {getConnectedIntegration} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -36,6 +36,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {ListRenderItemInfo} from '@shopify/flash-list'; import {companyCardCustomNamesSelector} from '@selectors/Card'; +import {Str} from 'expensify-common'; import React, {useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -204,14 +205,14 @@ function WorkspaceCompanyCardsTable({ label: translate('common.member'), sortable: true, dynamicSizing: { - // The cell stacks the member's name above the card details or login, so whichever renders wider decides - // the column's width. + // Mirrors the row's own title/subtitle: the cardholder's name (or the unassigned label) stacked above + // their login, so whichever renders wider decides the column's width. getContentToMeasure: (item) => [ { - text: item.isAssigned ? (item.cardholder?.displayName ?? item.cardholder?.login ?? '') : translate('workspace.moreFeatures.companyCards.unassignedCards'), + text: item.isAssigned ? Str.removeSMSDomain(item.cardholder?.displayName ?? '') : translate('workspace.moreFeatures.companyCards.unassignedCards'), fontSize: fontScale.text, }, - {text: item.customCardName ?? '', fontSize: fontScale.label}, + {text: item.isAssigned ? Str.removeSMSDomain(item.cardholder?.login ?? '') : '', fontSize: fontScale.label}, ], extraWidth: MEMBER_CELL_AVATAR_WIDTH, }, @@ -222,7 +223,7 @@ function WorkspaceCompanyCardsTable({ sortable: true, dynamicSizing: { // Masked card numbers all render at the same length, so this column always fits them in full. - getContentToMeasure: (item) => [{text: item.cardName, fontSize: fontScale.text}], + getContentToMeasure: (item) => [{text: formatMaskedCardName(item.cardName), fontSize: fontScale.text}], shouldFitContent: true, }, }, @@ -251,15 +252,10 @@ function WorkspaceCompanyCardsTable({ key: 'actions', label: '', sortable: false, + width: variables.companyCardsTableActionColumnWidth, styling: { containerStyles: [styles.justifyContentEnd, styles.pr3], }, - dynamicSizing: { - // Only unassigned rows render the Assign button, so that is the widest content this column ever holds. - getContentToMeasure: () => [{text: translate('workspace.companyCards.assign'), fontSize: fontScale.text, fontWeight: '700'}], - extraWidth: variables.iconSizeNormal + styles.gap3.gap, - shouldFitContent: true, - }, }, ]; diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx index 0631e5c0c4a5..09e6c9cecfc2 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {filterCardsByPersonalDetails, getTranslationKeyForCardStatus, getTranslationKeyForLimitType} from '@libs/CardUtils'; import {convertToShortDisplayString} from '@libs/CurrencyUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; +import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import WorkspaceCardListLabels from '@pages/workspace/expensifyCard/WorkspaceCardListLabels'; @@ -108,7 +109,7 @@ export default function WorkspaceExpensifyCardsTable({ listContentContainerStyle, }: WorkspaceExpensifyCardsTableProps) { const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); + const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; @@ -129,7 +130,7 @@ export default function WorkspaceExpensifyCardsTable({ // The cell stacks the cardholder's name above the card's own title, so whichever renders wider decides // the column's width. getContentToMeasure: (item) => [ - {text: item.cardholder?.displayName ?? item.cardholder?.login ?? '', fontSize: fontScale.text}, + {text: temporaryGetDisplayNameOrDefault({passedPersonalDetails: item.cardholder, translate, formatPhoneNumber}), fontSize: fontScale.text}, {text: item.name, fontSize: fontScale.label}, ], extraWidth: MEMBER_CELL_AVATAR_WIDTH, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 92711a20f215..a8e2744ee857 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -153,6 +153,7 @@ export default { domainTableActionColumnWidth: 64, domainAdminsTableActionColumnWidth: 140, agentsTableActionColumnWidth: 260, + companyCardsTableActionColumnWidth: 140, workspaceTableActionColumnWidth: 64, workspaceMembersRoleColumnWidth: 148, sectionMenuItemHeight: 52, From 719126970051fa93e8d1133a40d023a107628835 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:28:07 +0700 Subject: [PATCH 157/485] Apply reselection does nothing behaviro for Xero default contact selector --- .../DynamicXeroNonReimbursableDefaultContactSelectPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 1b30a0737d04..176062f64e18 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -113,7 +113,10 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy // Treat the clear row and an already-empty default as the same state so picking // "None" on a workspace that never had a default doesn't fire a no-op write. const isAlreadySelected = value === currentContactID || (!value && !currentContactID); - if (!isAlreadySelected && policyID) { + if (isAlreadySelected) { + return; + } + if (policyID) { updateManyPolicyConnectionConfigs( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, From 1bd818a3e784012ef05807d3c281cb5e755e38d4 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:28:59 +0700 Subject: [PATCH 158/485] Add None option to Rillet default vendor selector --- .../export/RilletDefaultCompanyCardVendorPage.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx index 0b3a7918aaad..3725b3b238c9 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx @@ -39,14 +39,22 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps const defaultCompanyCardVendorID = rilletConfig?.export?.defaultVendorID; const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_RILLET_EXPORT.getRoute(policyID) : undefined; - const data: VendorListItem[] = + const vendorOptions: VendorListItem[] = rilletData?.vendors?.map((vendorItem) => ({ value: vendorItem.id, text: vendorItem.name, keyForList: vendorItem.id, isSelected: defaultCompanyCardVendorID === vendorItem.id, })) ?? []; - const {filteredData, textInputOptions} = useSelectionListSearch(data); + const clearOption: VendorListItem = { + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !defaultCompanyCardVendorID, + }; + const shouldShowClearOption = !!defaultCompanyCardVendorID || vendorOptions.length > 0; + const {filteredData: filteredVendorOptions, textInputOptions} = useSelectionListSearch(vendorOptions); + const data: VendorListItem[] = shouldShowClearOption ? [clearOption, ...filteredVendorOptions] : filteredVendorOptions; const headerContent = ( @@ -79,7 +87,7 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} displayName="RilletDefaultCompanyCardVendorPage" title="workspace.rillet.defaultCompanyCardVendor.label" - data={filteredData} + data={data} textInputOptions={textInputOptions} headerContent={headerContent} listEmptyContent={listEmptyContent} From ca0f6985183b043dc5ffa613b738aa1f32cc7bb3 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:29:13 +0700 Subject: [PATCH 159/485] Focus on None option in Rillet default vendor selector --- .../rillet/export/RilletDefaultCompanyCardVendorPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx index 3725b3b238c9..ee07579c5d75 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx @@ -93,7 +93,7 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps listEmptyContent={listEmptyContent} onSelectRow={selectDefaultVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={defaultCompanyCardVendorID} + initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : defaultCompanyCardVendorID} onBackButtonPress={() => Navigation.goBack(backPath)} connectionName={CONST.POLICY.CONNECTIONS.NAME.RILLET} pendingAction={settingsPendingAction([CONST.RILLET_CONFIG.DEFAULT_VENDORID], rilletConfig?.pendingFields)} From 4c047156c7bb0ad3225e6c69827236547d5ef80a Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:29:38 +0700 Subject: [PATCH 160/485] Apply reselection does nothing behaviro for Rillet default vendor selector --- .../rillet/export/RilletDefaultCompanyCardVendorPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx index ee07579c5d75..5a3ea8a71f8f 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx @@ -74,7 +74,11 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps ); const selectDefaultVendor = (item: VendorListItem) => { - if (item.value !== defaultCompanyCardVendorID && policyID) { + const isAlreadySelected = item.value === defaultCompanyCardVendorID || (!item.value && !defaultCompanyCardVendorID); + if (isAlreadySelected) { + return; + } + if (policyID) { updateRilletDefaultVendor(policyID, item.value, defaultCompanyCardVendorID); } Navigation.goBack(backPath); From b7ce0b4aa18da819ba185ce060edf3c19a2a3f96 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:30:15 +0700 Subject: [PATCH 161/485] Add None option to QBO default vendor selector --- ...QuickbooksNonReimbursableVendorSelectPage.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx index 2223d346ff1b..a8014c3852dc 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx @@ -54,7 +54,7 @@ function QuickbooksNonReimbursableVendorSelectPage({policy, configKey, updateVen const currentVendor = qboConfig?.[configKey]; const policyID = policy?.id ?? CONST.DEFAULT_NUMBER_ID.toString(); - const data: CardListItem[] = + const vendorOptions: CardListItem[] = vendors?.map((vendor) => ({ value: vendor.id, text: vendor.name, @@ -62,8 +62,16 @@ function QuickbooksNonReimbursableVendorSelectPage({policy, configKey, updateVen isSelected: vendor.id === currentVendor, })) ?? []; - // Only the CC/DC export path treats a blank vendor as a valid state (falls back to "Credit Card Misc"), so we only allow clearing on that configKey. - const canClearByReSelecting = configKey === CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR; + // Only the CC/DC export path treats a blank vendor as a valid state (falls back to "Credit Card Misc"), so we only offer a "None" row on that configKey. + const canClear = configKey === CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR; + const clearOption: CardListItem = { + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + text: translate('common.none'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + isSelected: !currentVendor || currentVendor === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + }; + const shouldShowClearOption = canClear && (!!currentVendor || vendorOptions.length > 0); + const data: CardListItem[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; const selectVendor = (row: CardListItem) => { if (row.value === currentVendor) { @@ -104,7 +112,7 @@ function QuickbooksNonReimbursableVendorSelectPage({policy, configKey, updateVen data={data} onSelectRow={selectVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} + initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : data.find((mode) => mode.isSelected)?.keyForList} listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} onBackButtonPress={() => Navigation.goBack()} From 913f4cb76e0ea246070517197857d23b4b2aad28 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:30:29 +0700 Subject: [PATCH 162/485] Apply reselection does nothing behaviro for QBO default vendor selector --- .../QuickbooksNonReimbursableVendorSelectPage.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx index a8014c3852dc..1ee1c5e6f2c2 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx @@ -74,13 +74,11 @@ function QuickbooksNonReimbursableVendorSelectPage({policy, configKey, updateVen const data: CardListItem[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; const selectVendor = (row: CardListItem) => { - if (row.value === currentVendor) { - if (canClearByReSelecting) { - updateVendor(policyID, CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, currentVendor); - } - } else { - updateVendor(policyID, row.value, currentVendor); + const isAlreadySelected = row.value === currentVendor || (!row.value && !currentVendor); + if (isAlreadySelected) { + return; } + updateVendor(policyID, row.value, currentVendor); Navigation.goBack(); }; From 4d5defc3e15f13759c130dbdb756331eb7ea0d78 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:30:44 +0700 Subject: [PATCH 163/485] Add None option to QBD default vendor selector --- ...sktopNonReimbursableDefaultVendorSelectPage.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx index 63ed6500c4f4..7bb483c62af5 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx @@ -36,7 +36,7 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP const nonReimbursableBillDefaultVendor = qbdConfig?.export?.nonReimbursableBillDefaultVendor; const policyID = policy?.id ?? CONST.DEFAULT_NUMBER_ID.toString(); - const data: CardListItem[] = useMemo( + const vendorOptions: CardListItem[] = useMemo( () => vendors?.map((vendor) => ({ value: vendor.id, @@ -47,6 +47,18 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP [nonReimbursableBillDefaultVendor, vendors], ); + const clearOption: CardListItem = useMemo( + () => ({ + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !nonReimbursableBillDefaultVendor, + }), + [translate, nonReimbursableBillDefaultVendor], + ); + const shouldShowClearOption = !!nonReimbursableBillDefaultVendor || vendorOptions.length > 0; + const data: CardListItem[] = useMemo(() => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), [shouldShowClearOption, clearOption, vendorOptions]); + const selectVendor = useCallback( (row: CardListItem) => { if (row.value !== nonReimbursableBillDefaultVendor) { From d4e086f7f09e6d7821538b32979c84b1d3bffd22 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:30:56 +0700 Subject: [PATCH 164/485] Focus on None option in QBD default vendor selector --- .../QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx index 7bb483c62af5..54e8676b1634 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx @@ -93,7 +93,7 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP data={data} onSelectRow={selectVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} + initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : data.find((mode) => mode.isSelected)?.keyForList} listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.QBD} onBackButtonPress={() => Navigation.goBack()} From 58c2a78f34b1649323429c234470a167f31a5d38 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:31:19 +0700 Subject: [PATCH 165/485] Apply reselection does nothing behaviro for QBD default vendor selector --- ...ckbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx index 54e8676b1634..9135214f2d91 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx @@ -61,9 +61,11 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP const selectVendor = useCallback( (row: CardListItem) => { - if (row.value !== nonReimbursableBillDefaultVendor) { - updateQuickbooksDesktopNonReimbursableBillDefaultVendor(policyID, row.value, nonReimbursableBillDefaultVendor); + const isAlreadySelected = row.value === nonReimbursableBillDefaultVendor || (!row.value && !nonReimbursableBillDefaultVendor); + if (isAlreadySelected) { + return; } + updateQuickbooksDesktopNonReimbursableBillDefaultVendor(policyID, row.value, nonReimbursableBillDefaultVendor); Navigation.goBack(); }, [nonReimbursableBillDefaultVendor, policyID], From 0a2e60bf503d3e75f4f7dcc641a77c20c5fea153 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:32:28 +0700 Subject: [PATCH 166/485] Add None option to NetSuite default vendor selector --- ...micNetSuiteExportExpensesVendorSelectPage.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx index ae9a97b5ba98..26c149e01444 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx @@ -41,7 +41,21 @@ function DynamicNetSuiteExportExpensesVendorSelectPage({policy}: WithPolicyConne const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT.path); const config = policy?.connections?.netsuite?.options.config; - const netsuiteVendorOptions = useMemo(() => getNetSuiteVendorOptions(policy ?? undefined, config?.defaultVendor), [config?.defaultVendor, policy]); + const vendorOptions = useMemo(() => getNetSuiteVendorOptions(policy ?? undefined, config?.defaultVendor), [config?.defaultVendor, policy]); + const clearOption: SelectorType = useMemo( + () => ({ + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !config?.defaultVendor, + }), + [translate, config?.defaultVendor], + ); + const shouldShowClearOption = !!config?.defaultVendor || vendorOptions.length > 0; + const netsuiteVendorOptions = useMemo( + () => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), + [shouldShowClearOption, clearOption, vendorOptions], + ); const initiallyFocusedOptionKey = useMemo(() => netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteVendorOptions]); From 34add91a1907b1b915d506bb6d125685de6ec281 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:32:48 +0700 Subject: [PATCH 167/485] Focus on None option in NetSuite default vendor selector --- .../export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx index 26c149e01444..c67a4ab42775 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx @@ -57,7 +57,10 @@ function DynamicNetSuiteExportExpensesVendorSelectPage({policy}: WithPolicyConne [shouldShowClearOption, clearOption, vendorOptions], ); - const initiallyFocusedOptionKey = useMemo(() => netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteVendorOptions]); + const initiallyFocusedOptionKey = useMemo( + () => (shouldShowClearOption ? clearOption.keyForList : netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList), + [shouldShowClearOption, clearOption, netsuiteVendorOptions], + ); const goBack = useCallback(() => { Navigation.goBack(backPath); From b5d8a9eb5ce3d14edecf9be5cb18bc650916c570 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:33:14 +0700 Subject: [PATCH 168/485] Apply reselection does nothing behaviro for NetSuite default vendor selector --- .../DynamicNetSuiteExportExpensesVendorSelectPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx index c67a4ab42775..62d8f0822565 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx @@ -68,7 +68,11 @@ function DynamicNetSuiteExportExpensesVendorSelectPage({policy}: WithPolicyConne const updateDefaultVendor = useCallback( ({value}: SelectorType) => { - if (config?.defaultVendor !== value && policyID) { + const isAlreadySelected = value === config?.defaultVendor || (!value && !config?.defaultVendor); + if (isAlreadySelected) { + return; + } + if (policyID) { updateNetSuiteDefaultVendor(policyID, value, config?.defaultVendor); } goBack(); From 7a1c3c8c187a041c5d6c3f86c23869791e3b8d4f Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:35:29 +0700 Subject: [PATCH 169/485] Add None option to SageIntacct default vendor selector --- .../DynamicSageIntacctDefaultVendorPage.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx index c6c7f75801fd..4650f251ed4d 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx @@ -67,7 +67,24 @@ function DynamicSageIntacctDefaultVendorPage() { settingName = CONST.SAGE_INTACCT_CONFIG.REIMBURSABLE_VENDOR; } - const vendorSelectorOptions = useMemo(() => getSageIntacctVendors(policy, defaultVendor), [defaultVendor, policy]); + // Only the non-reimbursable credit-card-charge path treats a blank vendor as a valid state (falls back to "Credit Card Misc"), so we only offer a "None" row on that setting. + const canClear = settingName === CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_VENDOR; + + const vendorOptions = useMemo(() => getSageIntacctVendors(policy, defaultVendor), [defaultVendor, policy]); + const clearOption: SelectorType = useMemo( + () => ({ + value: CLEAR_DEFAULT_VENDOR, + text: translate('common.none'), + keyForList: CLEAR_DEFAULT_VENDOR, + isSelected: !defaultVendor, + }), + [translate, defaultVendor], + ); + const shouldShowClearOption = canClear && (!!defaultVendor || vendorOptions.length > 0); + const vendorSelectorOptions = useMemo( + () => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), + [shouldShowClearOption, clearOption, vendorOptions], + ); const listHeaderComponent = useMemo( () => ( From 046b8fe07e1d217d4c5334309ea168f6c6bfef53 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:35:40 +0700 Subject: [PATCH 170/485] Focus on None option in Sage Intacct default vendor selector --- .../intacct/export/DynamicSageIntacctDefaultVendorPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx index 4650f251ed4d..241676e301b8 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx @@ -135,7 +135,7 @@ function DynamicSageIntacctDefaultVendorPage() { displayName="SageIntacctDefaultVendorPage" data={vendorSelectorOptions ?? []} onSelectRow={updateDefaultVendor} - initiallyFocusedOptionKey={vendorSelectorOptions.find((mode) => mode.isSelected)?.keyForList} + initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : vendorSelectorOptions.find((mode) => mode.isSelected)?.keyForList} headerContent={listHeaderComponent} onBackButtonPress={goBack} title="workspace.sageIntacct.defaultVendor" From 098f242f3bf539aaaa4caa3e0586ce6a8b363559 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:35:58 +0700 Subject: [PATCH 171/485] Apply reselection does nothing behaviro for Sage Intacct default vendor selector --- .../DynamicSageIntacctDefaultVendorPage.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx index 241676e301b8..6ff8a3f32953 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx @@ -97,21 +97,18 @@ function DynamicSageIntacctDefaultVendorPage() { [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal, isReimbursable], ); - // Only the non-reimbursable credit-card-charge path treats a blank vendor as a valid state (falls back to "Credit Card Misc"), so we only allow clearing when that setting is active. - const canClearByReSelecting = settingName === CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_VENDOR; - const updateDefaultVendor = useCallback( ({value}: SelectorType) => { - if (value === defaultVendor) { - if (canClearByReSelecting) { - updateSageIntacctDefaultVendor(policyID, settingName, CLEAR_DEFAULT_VENDOR, defaultVendor); - } - } else { + const isAlreadySelected = value === defaultVendor || (!value && !defaultVendor); + if (canClear && isAlreadySelected) { + return; + } + if (!isAlreadySelected) { updateSageIntacctDefaultVendor(policyID, settingName, value, defaultVendor); } goBack(); }, - [defaultVendor, policyID, settingName, goBack, canClearByReSelecting], + [defaultVendor, policyID, settingName, goBack, canClear], ); const listEmptyContent = useMemo( From cbd1a150903674942cdef30a867fdd94c35e368f Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:36:20 +0700 Subject: [PATCH 172/485] Add None option to Dual Entry default vendor selector --- .../DualEntryDefaultCompanyCardVendorPage.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx index 133c376ba621..f35b0ccc7a6f 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx @@ -39,7 +39,7 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr const defaultCompanyCardVendorID = dualentryConfig?.export?.defaultVendorID; const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXPORT.getRoute(policyID) : undefined; - const data: VendorListItem[] = + const vendorOptions: VendorListItem[] = dualentryData?.vendors ?.filter((vendorItem) => vendorItem.isActive) .map((vendorItem) => ({ @@ -48,7 +48,15 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr keyForList: vendorItem.id, isSelected: defaultCompanyCardVendorID === vendorItem.id, })) ?? []; - const {filteredData, textInputOptions} = useSelectionListSearch(data); + const clearOption: VendorListItem = { + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !defaultCompanyCardVendorID, + }; + const shouldShowClearOption = !!defaultCompanyCardVendorID || vendorOptions.length > 0; + const {filteredData: filteredVendorOptions, textInputOptions} = useSelectionListSearch(vendorOptions); + const data: VendorListItem[] = shouldShowClearOption ? [clearOption, ...filteredVendorOptions] : filteredVendorOptions; const headerContent = ( @@ -81,7 +89,7 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} displayName="DualEntryDefaultCompanyCardVendorPage" title="workspace.dualEntry.defaultCompanyCardVendor.label" - data={filteredData} + data={data} textInputOptions={textInputOptions} headerContent={headerContent} listEmptyContent={listEmptyContent} From a32ce6dbca7c8ce0cb54505f2a90bfb127e1be2f Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:36:32 +0700 Subject: [PATCH 173/485] Focus on None option in Dual Entry default vendor selector --- .../dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx index f35b0ccc7a6f..8ea009b7cd2c 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx @@ -95,7 +95,7 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr listEmptyContent={listEmptyContent} onSelectRow={selectDefaultVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={defaultCompanyCardVendorID} + initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : defaultCompanyCardVendorID} onBackButtonPress={() => Navigation.goBack(backPath)} connectionName={CONST.POLICY.CONNECTIONS.NAME.DUALENTRY} pendingAction={settingsPendingAction([CONST.DUALENTRY_CONFIG.DEFAULT_VENDORID], dualentryConfig?.pendingFields)} From f6b6e05a1209f8cbc321a89a1111221ca6db8aea Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:36:48 +0700 Subject: [PATCH 174/485] Apply reselection does nothing behavior for Dual Entry default vendor selector --- .../export/DualEntryDefaultCompanyCardVendorPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx index 8ea009b7cd2c..912b521c5ad6 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx @@ -76,7 +76,11 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr ); const selectDefaultVendor = (item: VendorListItem) => { - if (item.value !== defaultCompanyCardVendorID && policyID) { + const isAlreadySelected = item.value === defaultCompanyCardVendorID || (!item.value && !defaultCompanyCardVendorID); + if (isAlreadySelected) { + return; + } + if (policyID) { updateDualEntryDefaultVendor(policyID, item.value, defaultCompanyCardVendorID); } Navigation.goBack(backPath); From 59bc9cde54d3485f8e48112971dfd5e843e5fc3f Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:37:21 +0700 Subject: [PATCH 175/485] Add None option to Certinia default vendor selector --- .../certinia/export/CertiniaDefaultVendorPage.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx index ce97572045f2..798ee8b227f8 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx @@ -36,12 +36,20 @@ function CertiniaDefaultVendorPage({policy}: WithPolicyConnectionsProps) { const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_DEFAULT_VENDOR.path); const illustrations = useMemoizedLazyIllustrations(['Telescope']); - const dataOptions: VendorListItem[] = vendors.map((vendor) => ({ + const vendorOptions: VendorListItem[] = vendors.map((vendor) => ({ value: vendor.id, text: vendor.name, keyForList: vendor.id, isSelected: exportConfig?.vendorAccount === vendor.id, })); + const clearOption: VendorListItem = { + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !exportConfig?.vendorAccount, + }; + const shouldShowClearOption = !!exportConfig?.vendorAccount || vendorOptions.length > 0; + const dataOptions: VendorListItem[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; const listEmptyContent = ( Date: Thu, 10 Sep 2026 02:37:31 +0700 Subject: [PATCH 176/485] Focus on None option in Certinia default vendor selector --- .../accounting/certinia/export/CertiniaDefaultVendorPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx index 798ee8b227f8..6dea1e2ec234 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx @@ -77,7 +77,7 @@ function CertiniaDefaultVendorPage({policy}: WithPolicyConnectionsProps) { data={dataOptions} onSelectRow={selectVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={exportConfig?.vendorAccount} + initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : exportConfig?.vendorAccount} onBackButtonPress={() => Navigation.goBack(backPath)} title="workspace.accounting.defaultVendor" listEmptyContent={listEmptyContent} From ff685e2b1445d9493a3b294252ee7915b8007389 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:37:39 +0700 Subject: [PATCH 177/485] Apply reselection does nothing behavior for Certinia default vendor selector --- .../certinia/export/CertiniaDefaultVendorPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx index 6dea1e2ec234..c0f44d6c9b4b 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx @@ -62,7 +62,11 @@ function CertiniaDefaultVendorPage({policy}: WithPolicyConnectionsProps) { ); const selectVendor = (row: VendorListItem) => { - if (row.value !== exportConfig?.vendorAccount && policyID) { + const isAlreadySelected = row.value === exportConfig?.vendorAccount || (!row.value && !exportConfig?.vendorAccount); + if (isAlreadySelected) { + return; + } + if (policyID) { updateFinancialForceDefaultVendor(policyID, row.value, exportConfig?.vendorAccount ?? null); } Navigation.goBack(backPath); From 672b405835509d02392601c7be7af5fd221c9285 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 02:49:42 +0700 Subject: [PATCH 178/485] Fix lint --- .../export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx index 62d8f0822565..7afa09c0ad5b 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx @@ -59,7 +59,7 @@ function DynamicNetSuiteExportExpensesVendorSelectPage({policy}: WithPolicyConne const initiallyFocusedOptionKey = useMemo( () => (shouldShowClearOption ? clearOption.keyForList : netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList), - [shouldShowClearOption, clearOption, netsuiteVendorOptions], + [shouldShowClearOption, clearOption.keyForList, netsuiteVendorOptions], ); const goBack = useCallback(() => { From 375616ee74274555a464c7057129b09f61e8f94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ste=C4=87=20=28via=20MelvinBot=29?= Date: Wed, 9 Sep 2026 19:51:38 +0000 Subject: [PATCH 179/485] Docs: document Preferred Workspace search for domain groups and fix UI labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jakub Steć --- .../Create-and-Manage-Domain-Groups.md | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/docs/articles/new-expensify/domains/Create-and-Manage-Domain-Groups.md b/docs/articles/new-expensify/domains/Create-and-Manage-Domain-Groups.md index 09a99e91464e..7c4f2a8f9974 100644 --- a/docs/articles/new-expensify/domains/Create-and-Manage-Domain-Groups.md +++ b/docs/articles/new-expensify/domains/Create-and-Manage-Domain-Groups.md @@ -1,8 +1,8 @@ --- title: Create and Manage Domain Groups description: Learn how to create and manage domain groups (domain security groups) to apply different permission rules to different sets of domain members. -internalScope: Audience is Domain Admins. Covers creating and managing Domain Groups (Domain Security Groups) and what each group permission controls. Does not cover workspace-level rules configuration or troubleshooting user access issues. -keywords: [New Expensify, domain groups, domain security groups, how to create domain group, restrict workspace creation, enforce workspace rules, require company email, preferred workspace, Expensify Card preferred workspace, domain permissions, Domain Admin] +internalScope: Audience is Domain Admins. Covers creating and managing Domain Groups (Domain Security Groups), setting a Preferred Workspace for a group, and what each group permission controls. Does not cover workspace-level rules configuration or troubleshooting user access issues. +keywords: [New Expensify, domain groups, domain security groups, how to create domain group, restrict workspace creation, enforce workspace rules, restrict default login selection, preferred workspace, search preferred workspace, find a workspace in domain group, Expensify Card preferred workspace, domain permissions, Domain Admin] ---
    @@ -27,7 +27,7 @@ Only **Domain Admins** can create and manage Domain Groups. 1. Click the navigation tabs (on the left on web, on the bottom on mobile). 2. Click **Workspaces**. -3. Scroll below your workspaces list to find **Domains**. +3. Click **Domains**. 4. Click your domain. 5. Click **Groups**. @@ -37,37 +37,60 @@ Only **Domain Admins** can create and manage Domain Groups. Follow the steps in **Where to find Domain Groups** above, then: -1. Click **Create group**. -2. Configure the group settings and permissions (see **What Domain Group permission settings control** below). -3. Click **Save**. +1. Click **New group**. +2. Enter a name for the group. +3. Configure the group settings and permissions (see **What Domain Group permission settings control** below). +4. Click **Create group**. + +--- + +## How to set a Preferred Workspace for a Domain Group + +Follow the steps in **Where to find Domain Groups** above, then: + +1. Click the group you want to update. +2. Turn on **Preferred Workspace**. +3. Click the **Preferred Workspace** row that appears below the toggle. +4. If you administer 12 or more workspaces, a **Search** field is shown above the list. Type part of a workspace name to filter the list. +5. Click the workspace you want to use. + +The list shows only the workspaces you administer. If a search returns nothing, **No results found** is shown, so clear the search and try a different name. + +You can set the **Preferred Workspace** the same way while creating a group, before you click **Create group**. + + --- ## What Domain Group permission settings control -## What the Default group setting does +## What the Default group for new members setting does -Enable this if you want all new domain members to be automatically added to this group. Setting a Default Group ensures new employees receive the correct permissions immediately. +Enable this if you want all new domain members to be automatically added to this group. Setting a default group ensures new employees receive the correct permissions immediately. -## What Strictly enforce expense workspace rules does +## What Strictly enforce workspace rules does -Use this to ensure workspace-level rules are followed before a report is submitted. Enabling Strictly enforce expense workspace rules ensures workspace rule compliance and prevents incomplete submissions. +Use this to ensure workspace-level rules are followed before a report is submitted. Enabling **Strictly enforce workspace rules** ensures workspace rule compliance and prevents incomplete submissions. -## What Restrict primary contact method selection does +## What Restrict default login selection does -Enable this to require members to use their company email address to access Expensify. Restricting primary contact method selection prevents members from using a personal email to access their Expensify account. +Enable this to require members to use their company email address to access Expensify. **Restrict default login selection** prevents members from changing their login email away from your company domain. ## What Restrict expense workspace creation/removal does -Enable this to prevent members from creating or removing workspaces. Restrict expense workspace creation/removal ensures centralized workspace management and prevents employees from creating additional workspaces outside the company’s approved setup. +Enable this to prevent members from creating or removing workspaces. **Restrict expense workspace creation/removal** ensures centralized workspace management and prevents employees from creating additional workspaces outside the company’s approved setup. -## What Preferred workspace does +## What Preferred Workspace does -Set a preferred workspace to automatically route a group’s expenses and reports to a specific workspace. This is helpful if different members use different workspaces and you want to reduce manual workspace selection. If you have multiple workspaces, use this to route a group’s expenses to the right workspace by default. +Set a **Preferred Workspace** to automatically route a group’s expenses and reports to a specific workspace. This is helpful if different members use different workspaces and you want to reduce manual workspace selection. If you have multiple workspaces, use this to route a group’s expenses to the right workspace by default. ## What Expensify Card preferred workspace does -If a preferred workspace is set, enable this option to automatically post **Expensify Card** transactions to that workspace. This ensures transactions are routed correctly and reconciliation is simplified. +If a **Preferred Workspace** is set, enable this option to automatically post **Expensify Card** transactions to that workspace instead. This ensures transactions are routed correctly and reconciliation is simplified. Your domain must have Expensify Cards set up before you can enable it. --- @@ -81,12 +104,20 @@ A Domain Group controls permissions and access settings at the domain level. A W No, each member can only belong to one Domain Group. -## What happens if I set a Default group? +## What happens if I set a default group? New domain members are automatically assigned to that group, ensuring they receive the correct permissions immediately. -## Does Preferred workspace move existing expenses? +## Does Preferred Workspace move existing expenses? + +No. **Preferred Workspace** applies to new expenses and reports going forward. + +## Why don’t I see a Search field on the Preferred Workspace page? + +The **Search** field is shown only when you administer 12 or more workspaces. With fewer than 12, the full list is short enough to scroll, so no search field is shown. + +## Which workspaces appear on the Preferred Workspace page? -No. Preferred workspace applies to new expenses and reports going forward. +Only the workspaces you administer. If a workspace is missing, ask a Workspace Admin of that workspace to add you as an admin.
    From cb3005fcb65e09c8f5fd138546ada7621075eb10 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Wed, 9 Sep 2026 19:52:36 +0000 Subject: [PATCH 180/485] Docs: document the Pay report global reimbursements prompt Co-authored-by: Shridhar Goel --- .../Global-Reimbursement-United-States.md | 16 ++++++++++++++++ .../wallet-and-payments/Pay-Expenses.md | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States.md b/docs/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States.md index f2d98153f53a..76cf36ef05c6 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States.md +++ b/docs/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States.md @@ -89,6 +89,22 @@ To begin reimbursing: --- +## How to start Enable Global Reimbursements from the Pay report prompt + +If your workspace has a verified USD business bank account that is not yet enabled for global reimbursements, and the submitter's deposit account is in a currency other than USD, Expensify prompts you at payment time instead of showing an error on the report. + +The prompt is titled **Pay report** and reads: This submitter has a non-USD bank account. Enable global reimbursements to pay the report, or ask them to add a USD bank account. + +1. Open the approved report and select **Pay**. +2. When the **Pay report** prompt appears, select **Enable Global Reimbursements**. +3. Complete the business information, agreement, and signer steps described above. + +Selecting **Enable Global Reimbursements** opens the same setup flow you can start from **Account > Wallet**, beginning at the business information step. + +Selecting **Cancel** closes the prompt without paying, and the report stays in the **Approved** state. The report cannot be reimbursed directly until global reimbursements are enabled on the USD business bank account, or the submitter adds a USD deposit account. + +--- + # FAQ ## How do employees add their deposit-only accounts? diff --git a/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md b/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md index 83ab0c1f3d9a..d221878838a8 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md +++ b/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md @@ -102,6 +102,16 @@ Only bank accounts that match the report's currency appear as payment options. I --- +## Why do I see a Pay report prompt asking me to enable global reimbursements? + +The **Pay report** prompt appears when your workspace's USD business bank account is not enabled for global reimbursements and the submitter's deposit account is in a currency other than USD. Expensify cannot send the payment until one of those changes. + +Select **Enable Global Reimbursements** to start setting up global reimbursements on the USD business bank account, or select **Cancel** to close the prompt and ask the submitter to add a USD deposit account instead. Selecting **Cancel** leaves the report in the **Approved** state. + +[Learn how to enable global reimbursements on a USD business bank account](/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States). + +--- + ## How do I pay a report with only non-reimbursable expenses? Reports containing only non-reimbursable expenses (e.g., company card charges) cannot be paid via ACH through Expensify. Instead, select **Mark as paid** from the **Pay** button dropdown to close the report. From 23d1d83f92fe35ac3f846e6e661014b2d75fef13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ste=C4=87=20=28via=20MelvinBot=29?= Date: Wed, 9 Sep 2026 19:53:56 +0000 Subject: [PATCH 181/485] Docs: cover requesting admin access from the Access restricted screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jakub Steć --- .../domains/Claim-and-Verify-a-Domain.md | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md b/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md index 518b6c76489d..8e00091aa6a8 100644 --- a/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md +++ b/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md @@ -1,8 +1,8 @@ --- title: Claim and Verify a Domain description: Learn how to claim and verify a private domain in New Expensify to enable SAML login and unlock enhanced security features. -internalScope: Audience is Domain Admins and IT admins. Covers claiming a domain, requesting admin access to a domain someone else already set up, and verifying domain ownership via DNS. Does not cover SAML configuration details, Domain Members management, Domain Groups, or login troubleshooting. -keywords: [New Expensify, claim domain, verify domain, private domain, domain settings, enhanced security, SAML, domain admin, domain verification, DNS TXT record, verify domain for SAML, how to verify domain, SAML setup, domain already set up, domain exists, ask for admin access, request admin access, domain already exists in your account] +internalScope: Audience is Domain Admins and IT admins. Covers claiming a domain, requesting admin access to a domain someone else already set up (from both the Domain exists page and the Access restricted screen), and verifying domain ownership via DNS. Does not cover SAML configuration details, Domain Members management, Domain Groups, or login troubleshooting. +keywords: [New Expensify, claim domain, verify domain, private domain, domain settings, enhanced security, SAML, domain admin, domain verification, DNS TXT record, verify domain for SAML, how to verify domain, SAML setup, domain already set up, domain exists, ask for admin access, request admin access, access restricted, verification required, verify yourself, request sent, domain already exists in your account] --- If you have a private domain (e.g., yourcompany.com), you can claim and verify it in Expensify to manage employee permissions and enable additional security features. @@ -47,7 +47,7 @@ Depending on your email setup: After the domain is successfully claimed, the domain will appear on the **Domains** tab in the **Not verified** state. -## How to request admin access to a domain someone else set up +## How to request admin access when you add a domain someone else set up A private domain can only be set up once. If another Expensify account already set up your domain, you can ask that domain’s admins to make you a Domain Admin. @@ -62,15 +62,33 @@ To leave without sending a request, click **Never mind** instead. Both buttons r +## How to request admin access from the Access restricted screen + +If a domain is already on your **Domains** tab but you are not a Domain Admin for it, opening that domain takes you to the **Access restricted** screen instead of the domain’s settings. You can ask for admin access from there. + +1. From the left-hand menu, select **Workspaces**, then select the **Domains** tab. +2. Select the domain you are not an admin of. +3. On the **Access restricted** screen, under the **Verification required** heading, click **Request admin access**. + +The button shows a spinner while the request sends, then changes to a disabled **Request sent** button. If the request fails, an error message appears below the buttons and you can click **Request admin access** again to retry. **Request admin access** is disabled while you are offline. + +To prove you are an authorized company administrator yourself instead of waiting on an existing admin, click **Verify yourself** on the same screen to start DNS verification. + + + ## What happens after you ask for admin access - Your request goes to the existing Domain Admins for that domain. An admin has to add you before you can manage it. -- The domain does **not** appear on your **Domains** tab while the request is pending, because you don’t have access to it yet. -- If you start the flow again for the same domain before an admin responds, the **Domain exists** page shows a disabled **Request sent** button so you don’t send a duplicate request. +- A domain you asked about from the **Domain exists** page does **not** appear on your **Domains** tab while the request is pending, because you don’t have access to it yet. +- If you ask again for the same domain before an admin responds, the button is disabled and reads **Request sent**, so you don’t send a duplicate request. This applies on both the **Domain exists** page and the **Access restricted** screen. [Learn how to add and manage Domain Admins](/articles/new-expensify/domains/Domain-Admins) to see what you'll be able to do once an admin adds you. @@ -104,10 +122,18 @@ Each private domain can only be set up once, and someone at your company got the Requesting access doesn't give you access. The domain only appears on your **Domains** tab after an existing Domain Admin adds you as a Domain Admin. +## Why do I see Access restricted when I open a domain? + +The domain is on your **Domains** tab, but you are not a Domain Admin for it, so you can't open its settings. Click **Request admin access** to ask an existing Domain Admin to add you, or click **Verify yourself** to verify that you're an authorized company administrator. + ## Why does the button say Request sent? You already asked for admin access to that domain and no admin has responded yet. The button is disabled so you don't send a duplicate request. +## Why can't I click Request admin access? + +The button is disabled while you're offline, and it's also disabled after your request goes through, when it reads **Request sent**. Reconnect and reopen the **Access restricted** screen if you're offline. + ## Can I verify multiple domains? Yes, you can claim and verify multiple domains if your organization operates under more than one. Each must be verified through its own DNS record. From 2b45b50af06fbc3864c8b5973b028a2345b9160f Mon Sep 17 00:00:00 2001 From: "dmkt9 (via MelvinBot)" Date: Wed, 9 Sep 2026 19:55:17 +0000 Subject: [PATCH 182/485] Docs: correct Workspace Members invite flow steps to match live UI labels The article documented 'Invite Member' and put Role/Message on the search step. The live flow uses the sentence-case 'Invite member' button, then Next, which opens Confirm details where Role, Message, and Invite live. Co-authored-by: dmkt9 --- .../workspaces/Managing-Workspace-Members.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md index ff9da16ebd28..44034aa810ac 100644 --- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md +++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md @@ -24,11 +24,12 @@ A **Workspace member** is anyone added to a workspace with one of the following To invite someone to your workspace: 1. Go to **Workspaces > [Workspace Name] > Members**. -2. Click **Invite Member**. -3. Search for the user by name, email, or phone number. -4. (Optional) Click **Role** to change the default role (Member). -5. (Optional) Add a message to the invitation. -6. Click **Invite**. +2. Click **Invite member**. +3. Search for the user by name, email, or phone number, then select them from the list. +4. Click **Next** to open **Confirm details**. +5. (Optional) Click **Role** to change the default role (Member). +6. (Optional) Add a **Message** to the invitation. +7. Click **Invite**. **Tip:** You can also invite members under **Settings > Profile** by clicking **Share** to send the workspace’s URL or QR code. From a10f40cd0edadf7c4881576602947f8afd75335c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 13:38:01 +0530 Subject: [PATCH 183/485] Trim redundant equivalence test loop Signed-off-by: krishna2323 --- tests/unit/CompanyCardExportTest.ts | 96 ++--------------------------- 1 file changed, 4 insertions(+), 92 deletions(-) diff --git a/tests/unit/CompanyCardExportTest.ts b/tests/unit/CompanyCardExportTest.ts index 9831813c9c33..9036c5c893c0 100644 --- a/tests/unit/CompanyCardExportTest.ts +++ b/tests/unit/CompanyCardExportTest.ts @@ -729,98 +729,10 @@ describe('getExportMenuItem - unsupported connections', () => { }); describe('getPolicyCardExportSettings + getCardExportAccountTitle', () => { - const connectionsToTest = [ - { - name: CONST.POLICY.CONNECTIONS.NAME.QBO, - policy: createBasePolicy({ - quickbooksOnline: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, - data: {creditCards: QBO_CREDIT_CARDS}, - }, - }), - nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT, - matchingAccountID: 'qbo-cc-1', - }, - { - name: CONST.POLICY.CONNECTIONS.NAME.XERO, - policy: createBasePolicy({xero: {config: {export: {nonReimbursableAccount: 'xero-bank-1'}}, data: {bankAccounts: XERO_BANK_ACCOUNTS}}}), - nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_XERO_EXPORT_BANK_ACCOUNT, - matchingAccountID: 'xero-bank-2', - }, - { - name: CONST.POLICY.CONNECTIONS.NAME.NETSUITE, - policy: createBasePolicy({ - netsuite: {options: {config: {nonreimbursableExpensesExportDestination: CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL}, data: {vendors: NETSUITE_VENDORS}}}, - }), - nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_NETSUITE_EXPORT_VENDOR, - matchingAccountID: 'ns-vendor-2', - }, - { - name: CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, - policy: createBasePolicy({ - intacct: {config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL}}, data: {vendors: INTACCT_VENDORS}}, - }), - nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_INTACCT_EXPORT_VENDOR, - matchingAccountID: 'intacct-vendor-2', - }, - { - name: CONST.POLICY.CONNECTIONS.NAME.QBD, - policy: createQBDPolicy(), - nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_DESKTOP_EXPORT_ACCOUNT_CREDIT, - matchingAccountID: '80000104-1746639411', - }, - { - name: CONST.POLICY.CONNECTIONS.NAME.RILLET, - policy: createBasePolicy({ - rillet: { - config: { - export: { - exportToMultipleAccounts: true, - reimbursable: CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL, - nonReimbursable: CONST.RILLET_EXPORT_NON_REIMBURSABLE.CREDIT_CARD_CHARGE, - creditCardAccountCode: '2100', - }, - }, - data: {accounts: RILLET_ACCOUNTS}, - }, - }), - nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, - matchingAccountID: 'rillet-2', - }, - { - name: CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, - policy: createBasePolicy({ - dualEntry: { - config: { - export: { - exportToMultipleAccounts: true, - reimbursable: CONST.DUALENTRY_EXPORT_REIMBURSABLE.VENDOR_BILL, - nonReimbursable: CONST.DUALENTRY_EXPORT_NON_REIMBURSABLE.DIRECT_EXPENSE, - creditCardAccountID: 'de-1', - }, - }, - data: {accounts: DUALENTRY_ACCOUNTS}, - }, - }), - nvpKey: CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, - matchingAccountID: 'de-2', - }, - ] as const; - - it.each( - connectionsToTest.flatMap(({name, policy, nvpKey, matchingAccountID}) => [ - {name, policy, card: createCardWithExportNVP(nvpKey, matchingAccountID), label: `${name} - matched NVP`}, - {name, policy, card: createCardWithExportNVP(nvpKey), label: `${name} - unset NVP (default)`}, - {name, policy, card: createCardWithExportNVP(nvpKey, CONST.COMPANY_CARDS.DEFAULT_EXPORT_TYPE), label: `${name} - DEFAULT_EXPORT_TYPE`}, - ]), - )('$label: getCardExportAccountTitle matches getExportMenuItem().title', ({name, policy, card}) => { - const settings = getPolicyCardExportSettings(name, MOCK_POLICY_ID, translate, policy); - const legacyResult = getExportMenuItem(name, MOCK_POLICY_ID, translate, themeStyles, policy, card); - - expect(getCardExportAccountTitle(settings, card)).toBe(legacyResult?.title); - expect(settings?.shouldShowMenuItem).toBe(legacyResult?.shouldShowMenuItem); - }); - + // getExportMenuItem is a thin composition of these two functions (see utils.tsx), and every case above already + // exercises both of them through it with real title/shouldShowMenuItem assertions. So this describe only covers + // behavior that is specific to calling the two functions directly, the way the tables do: resolving many cards + // against one settings object, and the settings-not-found guard the unassigned-card row relies on. it('resolves a different title per card feed when a single settings object is reused across Rillet cards', () => { const policy = createBasePolicy({ rillet: { From 275881cb4bc7fc58328083b6c4bf82de79cf3144 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 14:24:00 +0530 Subject: [PATCH 184/485] Fix dynamic column width regressions on the Export account column Signed-off-by: krishna2323 --- src/CONST/index.ts | 3 --- .../Table/useDynamicColumnWidths.ts | 9 ++++++++- .../WorkspaceCompanyCardsTable/index.tsx | 19 +++++++++++++++++-- .../WorkspaceExpensifyCardsTable/index.tsx | 2 -- src/styles/variables.ts | 1 - 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9cbfed2802ea..e5e49c7915eb 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8759,9 +8759,6 @@ const CONST = { /** How narrow a free-text column may be squeezed before the table scrolls instead, matching the ~180px default text column width table libraries use. */ MIN_FREE_TEXT_COLUMN_WIDTH: 180, - - /** Export account names are unbounded, so this column truncates rather than widening the table past its container. */ - MAX_EXPORT_ACCOUNT_COLUMN_WIDTH: 240, }, }, diff --git a/src/components/Table/useDynamicColumnWidths.ts b/src/components/Table/useDynamicColumnWidths.ts index 8ab74744c654..aadb1190af87 100644 --- a/src/components/Table/useDynamicColumnWidths.ts +++ b/src/components/Table/useDynamicColumnWidths.ts @@ -85,9 +85,16 @@ function measureColumnContentWidth !entry.isAssigned); + // Mirrors the Accounting section's own eligibility check on the card details page, so the column follows the same // rules as that section rather than introducing a second set of them. const syncingAccountingIntegration = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.find((integration) => integration === connectionSyncProgress?.connectionName); @@ -243,7 +247,6 @@ function WorkspaceCompanyCardsTable({ sortable: true, dynamicSizing: { getContentToMeasure: (item: WorkspaceCompanyCardTableItemData) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), - maxWidth: CONST.TABLES.DYNAMIC_COLUMNS.MAX_EXPORT_ACCOUNT_COLUMN_WIDTH, }, }, ] @@ -252,10 +255,22 @@ function WorkspaceCompanyCardsTable({ key: 'actions', label: '', sortable: false, - width: variables.companyCardsTableActionColumnWidth, styling: { containerStyles: [styles.justifyContentEnd, styles.pr3], }, + dynamicSizing: { + // A fixed width here would reserve the Assign button's space on every row, including the (usually + // more common) already-assigned rows that render only the arrow. Measuring instead means the column + // only claims that space when some row in the table can actually show the button. + getContentToMeasure: (item) => + canAnyCardBeAssigned && !item.isAssigned ? [{text: translate('workspace.companyCards.assign'), fontSize: fontScale.text, fontWeight: '700'}] : [], + // The Assign button and the arrow are a known, bounded pair of contents, so this column always shows + // them in full rather than truncating. + shouldFitContent: true, + // The button's own padding and its gap from the arrow only apply when some row can show it; otherwise + // the column only ever needs to fit the arrow itself. + extraWidth: canAnyCardBeAssigned ? styles.ph2.paddingHorizontal * 2 + styles.gap3.gap + variables.iconSizeNormal + styles.pr3.paddingRight : variables.iconSizeNormal, + }, }, ]; diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx index 09e6c9cecfc2..7a47b05bd72a 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -16,7 +16,6 @@ import WorkspaceCardListLabels from '@pages/workspace/expensifyCard/WorkspaceCar import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; import type {Card, PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; import type {CardLimitType} from '@src/types/onyx/Card'; import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; @@ -178,7 +177,6 @@ export default function WorkspaceExpensifyCardsTable({ sortable: true, dynamicSizing: { getContentToMeasure: (item: WorkspaceExpensifyCardTableRowData) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), - maxWidth: CONST.TABLES.DYNAMIC_COLUMNS.MAX_EXPORT_ACCOUNT_COLUMN_WIDTH, }, }, ] diff --git a/src/styles/variables.ts b/src/styles/variables.ts index a8e2744ee857..92711a20f215 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -153,7 +153,6 @@ export default { domainTableActionColumnWidth: 64, domainAdminsTableActionColumnWidth: 140, agentsTableActionColumnWidth: 260, - companyCardsTableActionColumnWidth: 140, workspaceTableActionColumnWidth: 64, workspaceMembersRoleColumnWidth: 148, sectionMenuItemHeight: 52, From 71ee8d642750e29e3df9ab6c18a1c46693b6f824 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 14:36:51 +0530 Subject: [PATCH 185/485] fix tests and knip check. Signed-off-by: krishna2323 --- src/pages/workspace/companyCards/utils.tsx | 1 - tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx | 8 +++++++- .../ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx | 8 +++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/companyCards/utils.tsx b/src/pages/workspace/companyCards/utils.tsx index b965b5416d89..22024e2325f8 100644 --- a/src/pages/workspace/companyCards/utils.tsx +++ b/src/pages/workspace/companyCards/utils.tsx @@ -618,4 +618,3 @@ function getCompanyCardDetailsBackPath( } export {getCardExportAccountTitle, getCompanyCardDetailsBackPath, getExportMenuItem, getPolicyCardExportSettings}; -export type {CardExportSettings}; diff --git a/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx b/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx index e59b5df94160..21e8bc1027e3 100644 --- a/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx +++ b/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx @@ -80,10 +80,16 @@ jest.mock('@components/Table', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View} = require('react-native'); - function MockTable({children, columns, data, compareItems}: MockTableProps) { + // Kept outside MockTable, which the React Compiler treats as a component: capturing render props into a module + // variable is only for this test's assertions and would otherwise look like an impure component body. + function captureTableProps(columns: MockTableProps['columns'], data: MockTableProps['data'], compareItems: MockTableProps['compareItems']) { capturedColumns = columns; capturedData = data; capturedCompareItems = compareItems; + } + + function MockTable({children, columns, data, compareItems}: MockTableProps) { + captureTableProps(columns, data, compareItems); return {children}; } diff --git a/tests/ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx b/tests/ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx index 63b07c89ec04..c9c5a16b0e36 100644 --- a/tests/ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx +++ b/tests/ui/WorkspaceExpensifyCardsExportAccountColumnTest.tsx @@ -44,9 +44,15 @@ jest.mock('@components/Table', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View} = require('react-native'); - function MockTable({children, columns, compareItems}: MockTableProps) { + // Kept outside MockTable, which the React Compiler treats as a component: capturing render props into a module + // variable is only for this test's assertions and would otherwise look like an impure component body. + function captureTableProps(columns: MockTableProps['columns'], compareItems: MockTableProps['compareItems']) { capturedColumns = columns; capturedCompareItems = compareItems; + } + + function MockTable({children, columns, compareItems}: MockTableProps) { + captureTableProps(columns, compareItems); return {children}; } From 21d4ba15c3d3eed58179e7832fd21bd057fd4ea2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 14:47:05 +0530 Subject: [PATCH 186/485] Make Expensify Card table's remaining columns fully dynamic Signed-off-by: krishna2323 --- .../WorkspaceExpensifyCardsTable/index.tsx | 31 +++++++++++-------- src/styles/variables.ts | 2 -- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx index 7a47b05bd72a..66ca97cb509b 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -119,12 +119,6 @@ export default function WorkspaceExpensifyCardsTable({ key: 'name', label: translate('workspace.expensifyCard.name'), sortable: true, - styling: { - // Cardholder names and card titles are the longest values in the table, so this column takes the - // space freed up by giving Type, Last 4 and Status fixed widths. This flex only applies to the static - // fallback layout, since dynamic column widths below resolve to px tracks instead. - flex: 2, - }, dynamicSizing: { // The cell stacks the cardholder's name above the card's own title, so whichever renders wider decides // the column's width. @@ -139,9 +133,12 @@ export default function WorkspaceExpensifyCardsTable({ key: 'type', label: translate('common.type'), sortable: true, - width: variables.tableTypeColumnWidth, - styling: { - containerStyles: [styles.mnw0], + dynamicSizing: { + getContentToMeasure: (item) => [ + {text: item.isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'), fontSize: fontScale.text}, + ], + // Type is one of exactly two known labels, so the column always shows them in full. + shouldFitContent: true, }, }, { @@ -158,15 +155,23 @@ export default function WorkspaceExpensifyCardsTable({ key: 'lastFour', label: translate('workspace.expensifyCard.lastFour'), sortable: true, - width: variables.tableLastFourColumnWidth, + dynamicSizing: { + getContentToMeasure: (item) => [{text: item.lastFourPAN, fontSize: fontScale.text}], + // The last 4 digits are always exactly 4 characters, so the column always shows them in full. + shouldFitContent: true, + }, }, { key: 'status', label: translate('common.status'), sortable: true, - width: variables.tableCardStatusColumnWidth, - styling: { - containerStyles: [styles.mnw0], + dynamicSizing: { + getContentToMeasure: (item) => { + const statusTranslationKey = getTranslationKeyForCardStatus(item.card.state, item.isVirtual); + return statusTranslationKey ? [{text: translate(statusTranslationKey), fontSize: fontScale.text}] : []; + }, + // A status is one of a short, known set of labels, so the column always shows them in full. + shouldFitContent: true, }, }, ...(shouldShowExportAccountColumn diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 92711a20f215..55e11ac073a6 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -147,8 +147,6 @@ export default { tableRequireFieldsTypeColumnWidth: 112, tableSwitchColumnWidth: 58, tableCaretColumnWidth: 20, - tableLastFourColumnWidth: 72, - tableCardStatusColumnWidth: 128, workspaceTagsTableCountColumnWidth: 100, domainTableActionColumnWidth: 64, domainAdminsTableActionColumnWidth: 140, From 4a018b7a7157fbfdfe96123b071e71d9f0708094 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 14:53:36 +0530 Subject: [PATCH 187/485] Trim comments on the export account column dynamic sizing Signed-off-by: krishna2323 --- .../WorkspaceCompanyCardsTable/index.tsx | 20 +++++-------------- .../WorkspaceExpensifyCardsTable/index.tsx | 9 +-------- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index 694420cc2ae2..c248828fdb8d 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -48,7 +48,6 @@ import WorkspaceCompanyCardTableItem from './WorkspaceCompanyCardsTableRow'; type CompanyCardsTableColumnKey = 'member' | 'card' | 'customCardName' | 'exportAccount' | 'actions'; -/** Width the member cell's avatar and the space after it take before the name and subtitle start. */ const MEMBER_CELL_AVATAR_WIDTH = variables.avatarSizeSmall + 12; type WorkspaceCompanyCardsTableHandle = { @@ -192,12 +191,10 @@ function WorkspaceCompanyCardsTable({ const shouldShowGBDisclaimer = isGB && (isNoFeed || hasNoAssignedCard); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; - // Whether any row currently renders the Assign button, so the actions column's dynamic sizing below can decide - // how much room to reserve for it: once a table's cards are all assigned, every row shows only the arrow. + // Drives the actions column's dynamic sizing below: once every card is assigned, no row shows the Assign button. const canAnyCardBeAssigned = canWriteCompanyCards && !isAssigningCardDisabled && (companyCardEntries ?? []).some((entry) => !entry.isAssigned); - // Mirrors the Accounting section's own eligibility check on the card details page, so the column follows the same - // rules as that section rather than introducing a second set of them. + // Mirrors the Accounting section's own eligibility check on the card details page, so the column follows the same rules. const syncingAccountingIntegration = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.find((integration) => integration === connectionSyncProgress?.connectionName); const connectedIntegration = getConnectedIntegration(policy, CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES) ?? syncingAccountingIntegration; const cardExportSettings = getPolicyCardExportSettings(connectedIntegration, policyID, translate, policy); @@ -209,8 +206,7 @@ function WorkspaceCompanyCardsTable({ label: translate('common.member'), sortable: true, dynamicSizing: { - // Mirrors the row's own title/subtitle: the cardholder's name (or the unassigned label) stacked above - // their login, so whichever renders wider decides the column's width. + // Whichever of the cardholder's name (or the unassigned label) or their login renders wider decides the column's width. getContentToMeasure: (item) => [ { text: item.isAssigned ? Str.removeSMSDomain(item.cardholder?.displayName ?? '') : translate('workspace.moreFeatures.companyCards.unassignedCards'), @@ -226,7 +222,6 @@ function WorkspaceCompanyCardsTable({ label: translate('workspace.companyCards.card'), sortable: true, dynamicSizing: { - // Masked card numbers all render at the same length, so this column always fits them in full. getContentToMeasure: (item) => [{text: formatMaskedCardName(item.cardName), fontSize: fontScale.text}], shouldFitContent: true, }, @@ -259,16 +254,11 @@ function WorkspaceCompanyCardsTable({ containerStyles: [styles.justifyContentEnd, styles.pr3], }, dynamicSizing: { - // A fixed width here would reserve the Assign button's space on every row, including the (usually - // more common) already-assigned rows that render only the arrow. Measuring instead means the column - // only claims that space when some row in the table can actually show the button. + // A fixed width here would reserve the Assign button's space even on rows that only render the arrow. getContentToMeasure: (item) => canAnyCardBeAssigned && !item.isAssigned ? [{text: translate('workspace.companyCards.assign'), fontSize: fontScale.text, fontWeight: '700'}] : [], - // The Assign button and the arrow are a known, bounded pair of contents, so this column always shows - // them in full rather than truncating. shouldFitContent: true, - // The button's own padding and its gap from the arrow only apply when some row can show it; otherwise - // the column only ever needs to fit the arrow itself. + // Only add the button's padding and gap when some row can actually show it. extraWidth: canAnyCardBeAssigned ? styles.ph2.paddingHorizontal * 2 + styles.gap3.gap + variables.iconSizeNormal + styles.pr3.paddingRight : variables.iconSizeNormal, }, }, diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx index 66ca97cb509b..729c5aa9734f 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -31,7 +31,6 @@ import {View} from 'react-native'; import WorkspaceExpensifyCardsTableRow from './WorkspaceExpensifyCardsTableRow'; -/** Width the member cell's avatar and the space after it take before the name and subtitle start. */ const MEMBER_CELL_AVATAR_WIDTH = variables.avatarSizeSmall + 12; type WorkspaceExpensifyCardTableColumnKey = 'name' | 'type' | 'limitType' | 'lastFour' | 'status' | 'exportAccount' | 'limit' | 'remainingLimit' | 'actions'; @@ -120,8 +119,7 @@ export default function WorkspaceExpensifyCardsTable({ label: translate('workspace.expensifyCard.name'), sortable: true, dynamicSizing: { - // The cell stacks the cardholder's name above the card's own title, so whichever renders wider decides - // the column's width. + // Whichever of the cardholder's name or the card's title renders wider decides the column's width. getContentToMeasure: (item) => [ {text: temporaryGetDisplayNameOrDefault({passedPersonalDetails: item.cardholder, translate, formatPhoneNumber}), fontSize: fontScale.text}, {text: item.name, fontSize: fontScale.label}, @@ -137,7 +135,6 @@ export default function WorkspaceExpensifyCardsTable({ getContentToMeasure: (item) => [ {text: item.isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'), fontSize: fontScale.text}, ], - // Type is one of exactly two known labels, so the column always shows them in full. shouldFitContent: true, }, }, @@ -147,7 +144,6 @@ export default function WorkspaceExpensifyCardsTable({ sortable: true, dynamicSizing: { getContentToMeasure: (item) => [{text: translate(getTranslationKeyForLimitType(item.limitType)), fontSize: fontScale.text}], - // A limit type is one of a short, known set of labels, so the column always shows them in full. shouldFitContent: true, }, }, @@ -157,7 +153,6 @@ export default function WorkspaceExpensifyCardsTable({ sortable: true, dynamicSizing: { getContentToMeasure: (item) => [{text: item.lastFourPAN, fontSize: fontScale.text}], - // The last 4 digits are always exactly 4 characters, so the column always shows them in full. shouldFitContent: true, }, }, @@ -170,7 +165,6 @@ export default function WorkspaceExpensifyCardsTable({ const statusTranslationKey = getTranslationKeyForCardStatus(item.card.state, item.isVirtual); return statusTranslationKey ? [{text: translate(statusTranslationKey), fontSize: fontScale.text}] : []; }, - // A status is one of a short, known set of labels, so the column always shows them in full. shouldFitContent: true, }, }, @@ -195,7 +189,6 @@ export default function WorkspaceExpensifyCardsTable({ }, dynamicSizing: { getContentToMeasure: (item) => [{text: convertToShortDisplayString(item.limit, item.currency), fontSize: fontScale.text}], - // A truncated currency amount is misleading, so this column always shows it in full. shouldFitContent: true, }, }, From 924bd9aa9a64bad92e5d47b275bb88a3eda16f41 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 17:14:52 +0700 Subject: [PATCH 188/485] Treat an unset QBO vendor as the selected None option --- .../qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx index 1ee1c5e6f2c2..3ff7ad4d505e 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx @@ -74,7 +74,8 @@ function QuickbooksNonReimbursableVendorSelectPage({policy, configKey, updateVen const data: CardListItem[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; const selectVendor = (row: CardListItem) => { - const isAlreadySelected = row.value === currentVendor || (!row.value && !currentVendor); + const isCurrentVendorNone = !currentVendor || currentVendor === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE; + const isAlreadySelected = row.value === currentVendor || (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE && isCurrentVendorNone); if (isAlreadySelected) { return; } From 04af33ec997508b7132349fd172c258c18a78d52 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 17:18:26 +0700 Subject: [PATCH 189/485] Normalize QBD's existing none sentinel in the clear row --- ...esktopNonReimbursableDefaultVendorSelectPage.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx index 9135214f2d91..c27e8ad8fb65 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx @@ -47,28 +47,29 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP [nonReimbursableBillDefaultVendor, vendors], ); + const isNonReimbursableBillDefaultVendorNone = !nonReimbursableBillDefaultVendor || nonReimbursableBillDefaultVendor === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE; const clearOption: CardListItem = useMemo( () => ({ - value: '', + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, text: translate('common.none'), - keyForList: '', - isSelected: !nonReimbursableBillDefaultVendor, + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + isSelected: isNonReimbursableBillDefaultVendorNone, }), - [translate, nonReimbursableBillDefaultVendor], + [translate, isNonReimbursableBillDefaultVendorNone], ); const shouldShowClearOption = !!nonReimbursableBillDefaultVendor || vendorOptions.length > 0; const data: CardListItem[] = useMemo(() => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), [shouldShowClearOption, clearOption, vendorOptions]); const selectVendor = useCallback( (row: CardListItem) => { - const isAlreadySelected = row.value === nonReimbursableBillDefaultVendor || (!row.value && !nonReimbursableBillDefaultVendor); + const isAlreadySelected = row.value === nonReimbursableBillDefaultVendor || (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE && isNonReimbursableBillDefaultVendorNone); if (isAlreadySelected) { return; } updateQuickbooksDesktopNonReimbursableBillDefaultVendor(policyID, row.value, nonReimbursableBillDefaultVendor); Navigation.goBack(); }, - [nonReimbursableBillDefaultVendor, policyID], + [nonReimbursableBillDefaultVendor, isNonReimbursableBillDefaultVendorNone, policyID], ); const listEmptyContent = useMemo( From 85d60c7595b6eefefdbc812cd10fb8ccacda1c7f Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 10 Sep 2026 17:18:48 +0700 Subject: [PATCH 190/485] Return for every already-selected Intacct vendor --- .../export/DynamicSageIntacctDefaultVendorPage.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx index 6ff8a3f32953..c0bdcabd87d8 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx @@ -100,15 +100,13 @@ function DynamicSageIntacctDefaultVendorPage() { const updateDefaultVendor = useCallback( ({value}: SelectorType) => { const isAlreadySelected = value === defaultVendor || (!value && !defaultVendor); - if (canClear && isAlreadySelected) { + if (isAlreadySelected) { return; } - if (!isAlreadySelected) { - updateSageIntacctDefaultVendor(policyID, settingName, value, defaultVendor); - } + updateSageIntacctDefaultVendor(policyID, settingName, value, defaultVendor); goBack(); }, - [defaultVendor, policyID, settingName, goBack, canClear], + [defaultVendor, policyID, settingName, goBack], ); const listEmptyContent = useMemo( From 926ece4b74445c78e67fbca62da579bf5483c39e Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 10 Sep 2026 14:17:09 +0200 Subject: [PATCH 191/485] refactor: split `MenuItemField` so errorText and hintText can be handled --- .../MenuItem/presets/MenuItemField.tsx | 63 +++++++++++++------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx index 81308f96e27d..27000085385a 100644 --- a/src/components/MenuItem/presets/MenuItemField.tsx +++ b/src/components/MenuItem/presets/MenuItemField.tsx @@ -10,18 +10,49 @@ import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItem import {callFunctionIfActionIsAllowed} from '@userActions/Session'; +import type {PropsWithChildren} from 'react'; + import React from 'react'; -type MenuItemFieldProps = Omit & { +type MenuItemFieldRowProps = PropsWithChildren<{ /** Name of the field */ name: string; /** Value the field holds. Omit it, or pass an empty string, for a field not filled in yet */ value?: string; -}; +}>; + +type MenuItemFieldProps = Omit & Omit; + +/** + * The line a field preset draws, without a `MenuItem.Root` of its own. Reach for it over the + * `MenuItemField` preset when the row needs siblings inside the same `Root` — an error or a hint + * line under the row, say — and render the trailing leaves, chevron included, as its children. + */ +function MenuItemFieldRow({name, value, children}: MenuItemFieldRowProps) { + return ( + + + {value ? ( + <> + {name} + {value} + + ) : ( + {name} + )} + + {!!children && {children}} + + ); +} -/** Field preset: a field name plus its value. With no `value` the name takes over the row */ -function MenuItemField({name, value, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) { +/** + * Field preset: a field name plus its value. With no `value` the name takes over the row. + * + * `children` land in the trailing cell, next to the chevron — not under the row. + */ +function MenuItemFieldPreset({name, value, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) { return ( - - - {value ? ( - <> - {name} - {value} - - ) : ( - {name} - )} - + {(!!children || !!onPress) && ( - + <> {children} {!!onPress && } - + )} - + ); } +const MenuItemField = Object.assign(MenuItemFieldPreset, {Row: MenuItemFieldRow}); + export default MenuItemField; +export type {MenuItemFieldProps, MenuItemFieldRowProps}; From ef55d26201517f0fc444698cfad375517940a98d Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 10 Sep 2026 14:17:35 +0200 Subject: [PATCH 192/485] refactor: migrate to MenuItemField --- src/components/CountryPicker/index.tsx | 33 +++++++++---- src/components/CurrencyPicker.tsx | 35 ++++++++++---- src/components/StatePicker/index.tsx | 33 +++++++++---- src/pages/Debug/ConstantSelector.tsx | 38 +++++++++++---- src/pages/Debug/DateTimeSelector.tsx | 38 +++++++++++---- .../Wallet/ExpensifyCardPage/index.tsx | 33 ++++++++----- .../Wallet/WalletPage/CardDetails.tsx | 23 ++++++--- .../advanced/DualEntryAdvancedPage.tsx | 33 ++++++++----- .../export/DualEntryCardAccountPage.tsx | 45 +++++++++++------- .../DualEntryCardProgramAccountPage.tsx | 47 +++++++++++-------- .../advanced/SageIntacctAdvancedPage.tsx | 39 ++++++++------- .../import/SageIntacctToggleMappingsPage.tsx | 31 ++++++++---- .../advanced/NetSuiteAdvancedPage.tsx | 31 ++++++++---- .../NetSuiteCustomListPicker.tsx | 37 +++++++++++---- ...ksDesktopCompanyCardExpenseAccountPage.tsx | 28 +++++++---- ...topOutOfPocketExpenseConfigurationPage.tsx | 30 ++++++++---- ...uickbooksCompanyCardExpenseAccountPage.tsx | 28 +++++++---- ...oksOutOfPocketExpenseConfigurationPage.tsx | 31 ++++++++---- .../rillet/advanced/RilletAdvancedPage.tsx | 33 ++++++++----- .../rillet/export/RilletCardAccount.tsx | 45 +++++++++++------- .../export/RilletCardProgramAccount.tsx | 47 +++++++++++-------- .../addNew/ImportFromFileStep.tsx | 31 ++++++++---- ...namicWorkspaceExpensifyCardDetailsPage.tsx | 30 ++++++++---- src/pages/workspace/taxes/TaxValuePicker.tsx | 35 ++++++++++---- 24 files changed, 565 insertions(+), 269 deletions(-) diff --git a/src/components/CountryPicker/index.tsx b/src/components/CountryPicker/index.tsx index a5cfa603a428..3252b6ca8915 100644 --- a/src/components/CountryPicker/index.tsx +++ b/src/components/CountryPicker/index.tsx @@ -1,9 +1,14 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; import type {Option} from '@libs/searchOptions'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -23,6 +28,7 @@ type CountryPickerProps = { function CountryPicker({value, errorText, onInputChange = () => {}}: CountryPickerProps) { const {translate} = useLocalize(); + const styles = useThemeStyles(); const [isPickerVisible, setIsPickerVisible] = useState(false); const hidePickerModal = () => { @@ -36,14 +42,23 @@ function CountryPicker({value, errorText, onInputChange = () => {}}: CountryPick return ( <> - setIsPickerVisible(true)} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - /> + setIsPickerVisible(true))}> + + {!!errorText && } + + + {!!errorText && ( + + )} + - setIsPickerVisible(true)} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - disabled={disabled} - /> + setIsPickerVisible(true))} + isDisabled={disabled} + > + + {!!errorText && } + + + {!!errorText && ( + + )} + {}}: StatePickerProps) { const {translate} = useLocalize(); + const styles = useThemeStyles(); const [isPickerVisible, setIsPickerVisible] = useState(false); const hidePickerModal = () => { @@ -39,14 +45,23 @@ function StatePicker({value, errorText, onInputChange = () => {}}: StatePickerPr return ( <> - setIsPickerVisible(true)} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - /> + setIsPickerVisible(true))}> + + {!!errorText && } + + + {!!errorText && ( + + )} + | undefined)?.[name]; useEffect(() => { @@ -59,16 +66,27 @@ function ConstantSelector({formType, policyID, errorText = '', name, value, onIn }, [fieldValue, name, onInputChange]); return ( - { + { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.DETAILS_CONSTANT_PICKER.getRoute(formType, name, value, policyID))); - }} - shouldShowRightIcon - /> + })} + > + + {!!errorText && } + + + {!!errorText && ( + + )} + ); } diff --git a/src/pages/Debug/DateTimeSelector.tsx b/src/pages/Debug/DateTimeSelector.tsx index 0851979cfd12..7a416ee609b1 100644 --- a/src/pages/Debug/DateTimeSelector.tsx +++ b/src/pages/Debug/DateTimeSelector.tsx @@ -1,8 +1,14 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; + +import useThemeStyles from '@hooks/useThemeStyles'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -34,6 +40,7 @@ type DateTimeSelectorProps = { // eslint-disable-next-line @typescript-eslint/no-unused-vars function DateTimeSelector({errorText = '', name, value, onInputChange, ref}: DateTimeSelectorProps) { + const styles = useThemeStyles(); const fieldValue = (useRoute().params as Record | undefined)?.[name]; useEffect(() => { @@ -53,16 +60,27 @@ function DateTimeSelector({errorText = '', name, value, onInputChange, ref}: Dat }, [fieldValue, name, onInputChange]); return ( - { + { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.DETAILS_DATE_TIME_PICKER.getRoute(name, value))); - }} - shouldShowRightIcon - /> + })} + > + + {!!errorText && } + + + {!!errorText && ( + + )} + ); } diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 65ff1016a008..e78f745e030f 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -6,10 +6,13 @@ import Button from '@components/ButtonComposed'; import CardPreview from '@components/CardPreview'; import ConfirmModal from '@components/ConfirmModal'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import FormHelpMessage from '@components/FormHelpMessage'; import FrozenCardHeader from '@components/FrozenCardHeader'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; +import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context'; @@ -435,18 +438,24 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { titleStyle={styles.walletCardLimit} hintText={remainingLimitHint} /> - + + + + {shouldShowReportLostCardButton && ( <> )} {!!limitType && ( - + + + {!!cardHintText && ( + + )} + )} {expiration?.length > 0 && ( - (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXPORT_METHOD.getRoute(policyID)) : undefined)} - shouldShowRightIcon - brickRoadIndicator={ - areSettingsInErrorFields([CONST.DUALENTRY_CONFIG.ACCOUNTING_METHOD], dualentryConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined - } - /> + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXPORT_METHOD.getRoute(policyID)) : undefined))} + > + + {areSettingsInErrorFields([CONST.DUALENTRY_CONFIG.ACCOUNTING_METHOD], dualentryConfig?.errorFields) && ( + + )} + + + + diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx index ad0f83bd783a..2a4afb4c2583 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx @@ -1,5 +1,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; @@ -15,6 +17,8 @@ import Navigation from '@libs/Navigation/Navigation'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -68,22 +72,29 @@ function DualEntryCardAccountPage({policy}: WithPolicyConnectionsProps) { key={feedKey} pendingAction={getCardsCustomExportPendingAction(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, feedKey)} > - (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined)} - shouldShowRightIcon - brickRoadIndicator={ - areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, feedKey) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - /> + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, feedKey) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} +
    ); })} diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx index a5ea6dae67d0..b655b63fade5 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx @@ -1,5 +1,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; @@ -16,6 +18,8 @@ import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -69,24 +73,29 @@ function DualEntryCardProgramAccountPage({policy}: WithPolicyConnectionsProps) { key={feedKey} pendingAction={settingsPendingAction([`${CONST.DUALENTRY_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], dualentryConfig?.pendingFields)} > - - policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined - } - shouldShowRightIcon - brickRoadIndicator={ - areSettingsInErrorFields([`${CONST.DUALENTRY_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], dualentryConfig?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - /> + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areSettingsInErrorFields([`${CONST.DUALENTRY_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], dualentryConfig?.errorFields) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + ); })} diff --git a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx index 389ad157743f..68e04c1ac7bc 100644 --- a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx +++ b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx @@ -1,8 +1,8 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; +import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; @@ -26,6 +26,7 @@ import { updateSageIntacctSyncReimbursementAccountID, } from '@userActions/connections/SageIntacct'; import {clearSageIntacctErrorField} from '@userActions/Policy/Policy'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -109,23 +110,25 @@ function SageIntacctAdvancedPage({policy}: WithPolicyProps) { onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} > - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC.path))} - brickRoadIndicator={ - areSettingsInErrorFields([CONST.SAGE_INTACCT_CONFIG.AUTO_SYNC, CONST.SAGE_INTACCT_CONFIG.ACCOUNTING_METHOD], config?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - hintText={(() => { - if (!config?.autoSync?.enabled) { - return undefined; - } - return translate(`workspace.sageIntacct.accountingMethods.alternateText.${accountingMethod}` as TranslationPaths); - })()} - /> + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC.path)))}> + + {areSettingsInErrorFields([CONST.SAGE_INTACCT_CONFIG.AUTO_SYNC, CONST.SAGE_INTACCT_CONFIG.ACCOUNTING_METHOD], config?.errorFields) && ( + + )} + + + {!!config?.autoSync?.enabled && ( + + )} + {toggleSections.map((section) => ( - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_MAPPINGS_TYPE.getRoute(policyID, mappingName))} - brickRoadIndicator={areSettingsInErrorFields([mappingName], config?.errorFields) ? 'error' : undefined} - hintText={translationKeys?.descriptionKey ? translate(translationKeys?.descriptionKey) : undefined} - /> + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_MAPPINGS_TYPE.getRoute(policyID, mappingName)))}> + + {areSettingsInErrorFields([mappingName], config?.errorFields) && } + + + {!!translationKeys?.descriptionKey && ( + + )} + diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx index 03cfc9ed27ca..3da55ff2e94a 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx @@ -1,6 +1,8 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useLocalize from '@hooks/useLocalize'; @@ -37,6 +39,8 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -85,14 +89,23 @@ function NetSuiteAdvancedPage({policy}: WithPolicyConnectionsProps) { key={item.description} pendingAction={settingsPendingAction(item.subscribedSettings, config?.pendingFields) ?? settingsPendingAction(item.subscribedSettings, autoSyncConfig?.pendingFields)} > - + + + {areSettingsInErrorFields(item.subscribedSettings, config?.errorFields) && } + + + {!!item.hintText && ( + + )} + ); }; diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx index 820a43ed1cd2..0a6d027f3bbf 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx @@ -1,9 +1,14 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -24,21 +29,33 @@ type NetSuiteCustomListPickerProps = { function NetSuiteCustomListPicker({value, policyID, errorText, isEditing}: NetSuiteCustomListPickerProps) { const {translate} = useLocalize(); + const styles = useThemeStyles(); return ( - { + { if (!policyID) { return; } Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_LIST_SELECTOR.getRoute(policyID, isEditing ? 'edit' : undefined)); - }} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - /> + })} + > + + {!!errorText && } + + + {!!errorText && ( + + )} + ); } diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx index b558cb1cae22..35efc4b3b0eb 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx @@ -1,8 +1,8 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; +import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; @@ -24,6 +24,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {clearQBDErrorField} from '@userActions/Policy/Policy'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -83,14 +84,23 @@ function DynamicQuickbooksDesktopCompanyCardExpenseAccountPage({policy}: WithPol key={section.keyForList} pendingAction={settingsPendingAction(section.subscribedSettings, qbdConfig?.pendingFields)} > - + + + {areSettingsInErrorFields(section.subscribedSettings, qbdConfig?.errorFields) && } + + + {!!section.hintText && ( + + )} + ))} {nonReimbursable === CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL && ( diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx index 818e1d908cd7..1e25ebffb868 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx @@ -1,5 +1,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -19,6 +21,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {clearQBDErrorField} from '@userActions/Policy/Policy'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -113,14 +116,23 @@ function DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage({policy}: W // eslint-disable-next-line react/no-array-index-key key={index} > - + + + {!!section.brickRoadIndicator && } + + + {!!section.hintText && ( + + )} + ))} {reimbursable === CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK && ( diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx index 12e6ba6e1ae8..318b3fd4f3df 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx @@ -2,7 +2,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -19,6 +18,8 @@ import {getQuickbooksOnlineIntegrationName} from '@pages/workspace/accounting/ut import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -93,14 +94,23 @@ function DynamicQuickbooksCompanyCardExpenseAccountPage({policy}: WithPolicyConn key={section.title} pendingAction={settingsPendingAction(section.subscribedSettings, qboConfig?.pendingFields)} > - + + + {areSettingsInErrorFields(section.subscribedSettings, qboConfig?.errorFields) && } + + + {!!section.hintText && ( + + )} + ))} {isVendorFeatureAvailable && ( diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx index 65f02c08b5dd..c850322e904a 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx @@ -1,5 +1,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -16,6 +18,8 @@ import {getQuickbooksOnlineIntegrationName} from '@pages/workspace/accounting/ut import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -133,14 +137,23 @@ function DynamicQuickbooksOutOfPocketExpenseConfigurationPage({policy}: WithPoli errors={section.errors} errorRowStyles={[styles.ph5]} > - + + + {!!section.brickRoadIndicator && } + + + {!!section.hintText && ( + + )} + ))} diff --git a/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx b/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx index 862f44d13d72..9f9bb0214a3e 100644 --- a/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx +++ b/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx @@ -1,8 +1,8 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; +import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; @@ -29,6 +29,8 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -94,16 +96,25 @@ function RilletAdvancedPage({policy}: WithPolicyConnectionsProps) { isToggleTriggered={shouldAnimateAutoSyncAccordionSection} > - (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_EXPORT_METHOD.getRoute(policyID)) : undefined)} - shouldShowRightIcon - brickRoadIndicator={ - areSettingsInErrorFields([CONST.RILLET_CONFIG.ACCOUNTING_METHOD], rilletConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined - } - /> + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_EXPORT_METHOD.getRoute(policyID)) : undefined))} + > + + {areSettingsInErrorFields([CONST.RILLET_CONFIG.ACCOUNTING_METHOD], rilletConfig?.errorFields) && ( + + )} + + + + diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx index 7b1423ceca6b..1a4b9d115ccc 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx @@ -1,5 +1,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; @@ -15,6 +17,8 @@ import Navigation from '@libs/Navigation/Navigation'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -68,22 +72,29 @@ function RilletCardAccount({policy}: WithPolicyConnectionsProps) { key={feedKey} pendingAction={getCardsCustomExportPendingAction(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, feedKey)} > - (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined)} - shouldShowRightIcon - brickRoadIndicator={ - areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, feedKey) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - /> + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, feedKey) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + ); })} diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx index 03b3a1804563..dd3dde58c241 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx @@ -1,5 +1,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; @@ -16,6 +18,8 @@ import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -69,24 +73,29 @@ function RilletCardProgramAccount({policy}: WithPolicyConnectionsProps) { key={feedKey} pendingAction={settingsPendingAction([`${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], rilletConfig?.pendingFields)} > - - policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined - } - shouldShowRightIcon - brickRoadIndicator={ - areSettingsInErrorFields([`${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], rilletConfig?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - /> + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areSettingsInErrorFields([`${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], rilletConfig?.errorFields) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + ); })} diff --git a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx index 6a07d5261373..54a1be6b079b 100644 --- a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx +++ b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx @@ -1,6 +1,8 @@ import Button from '@components/ButtonComposed'; +import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {PressableWithoutFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -21,6 +23,7 @@ import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; import {openLink} from '@userActions/Link'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -125,15 +128,23 @@ function ImportFromFileStep() { - Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_LAYOUT_NAME.getRoute(policyID))} - brickRoadIndicator={shouldShowLayoutNameError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={shouldShowLayoutNameError ? translate('workspace.companyCards.addNewCard.cardLayoutNameRequired') : undefined} - /> + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_LAYOUT_NAME.getRoute(policyID)))}> + + {!!shouldShowLayoutNameError && } + + + {!!shouldShowLayoutNameError && ( + + )} + } /> diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx index 9eb47d7d11e3..2af9a9233425 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchAdvancedFiltersParamList} from '@libs/Navigation/types'; -import {FILTER_VIEW_MAP} from '@libs/SearchUIUtils'; +import {FILTER_VIEW_MAP, isAmountFilterKey, isReportFieldKey, isTextFilterKey} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import {SearchAdvancedFiltersActionContext, SearchAdvancedFiltersContext} from '@pages/Search/SearchAdvancedFiltersProvider'; @@ -71,6 +71,8 @@ function SearchAdvancedFiltersContentBase() { const isFilterWithSelectionList = validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || + validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY || + validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.BANK_ACCOUNT || validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY || validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_CURRENCY || validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY || @@ -93,18 +95,7 @@ function SearchAdvancedFiltersContentBase() { } // We want to make space for the confirm button for filters with text inputs without list - if ( - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_AMOUNT || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID || - validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL - ) { + if (isAmountFilterKey(validFilterKey) || isTextFilterKey(validFilterKey) || isReportFieldKey(validFilterKey)) { return CONFIRM_BUTTON_HEIGHT; } From 61fe6bb94b428f1eac21f7c87bbb2f432e877e6e Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 16 Sep 2026 14:48:53 +0200 Subject: [PATCH 277/485] Derive hasAnyTodos from todoItems length --- src/pages/home/ForYouSection/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index f4c6eec12ff2..a6daa65dc87e 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -64,8 +64,6 @@ function ForYouSection({isConciergeMenuVisible, setIsConciergeMenuVisible}: ForY const payCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.PAY]; const exportCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.EXPORT]; - const hasAnyTodos = flaggedExpensesCount > 0 || submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0 || domainAdminRequestsCount > 0; - const navigateToReport = useCallback( (reportID: string) => { if (shouldUseNarrowLayout) { @@ -173,6 +171,8 @@ function ForYouSection({isConciergeMenuVisible, setIsConciergeMenuVisible}: ForY ], ); + const hasAnyTodos = todoItems.length > 0; + const forYouRows: React.ReactNode[] = todoItems.map(({key, count, icon, translationKey, handler, buttonVariant}) => ( Date: Wed, 16 Sep 2026 15:05:17 +0200 Subject: [PATCH 278/485] refactor: add MenuItemHelpText.tsx --- src/components/CountryPicker/index.tsx | 8 ++--- src/components/CurrencyPicker.tsx | 6 ++-- src/components/MenuItem/index.ts | 2 ++ .../MenuItem/leaves/MenuItemHelpText.tsx | 29 +++++++++++++++++++ src/components/StatePicker/index.tsx | 8 ++--- src/pages/Debug/ConstantSelector.tsx | 9 ++---- src/pages/Debug/DateTimeSelector.tsx | 9 ++---- .../domain/Admins/DomainAdminDetailsPage.tsx | 11 +------ .../Members/DomainMemberDetailsPage.tsx | 6 ++-- .../Wallet/ExpensifyCardPage/index.tsx | 5 +--- .../Wallet/WalletPage/CardDetails.tsx | 9 +----- .../advanced/DualEntryAdvancedPage.tsx | 7 +---- .../export/DualEntryCardAccountPage.tsx | 7 +---- .../DualEntryCardProgramAccountPage.tsx | 7 +---- .../advanced/SageIntacctAdvancedPage.tsx | 7 +---- .../import/SageIntacctToggleMappingsPage.tsx | 9 +----- .../advanced/NetSuiteAdvancedPage.tsx | 9 +----- .../NetSuiteCustomListPicker.tsx | 8 ++--- ...ksDesktopCompanyCardExpenseAccountPage.tsx | 9 +----- ...topOutOfPocketExpenseConfigurationPage.tsx | 9 +----- ...uickbooksCompanyCardExpenseAccountPage.tsx | 9 +----- ...oksOutOfPocketExpenseConfigurationPage.tsx | 9 +----- .../rillet/advanced/RilletAdvancedPage.tsx | 7 +---- .../rillet/export/RilletCardAccount.tsx | 7 +---- .../export/RilletCardProgramAccount.tsx | 7 +---- .../addNew/ImportFromFileStep.tsx | 6 ++-- ...namicWorkspaceExpensifyCardDetailsPage.tsx | 5 +--- src/pages/workspace/taxes/TaxValuePicker.tsx | 8 ++--- src/stories/MenuItemComparison.stories.tsx | 12 ++------ 29 files changed, 69 insertions(+), 175 deletions(-) create mode 100644 src/components/MenuItem/leaves/MenuItemHelpText.tsx diff --git a/src/components/CountryPicker/index.tsx b/src/components/CountryPicker/index.tsx index 89f734b44186..ea81cf27bdeb 100644 --- a/src/components/CountryPicker/index.tsx +++ b/src/components/CountryPicker/index.tsx @@ -1,9 +1,7 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; import type {Option} from '@libs/searchOptions'; @@ -28,7 +26,6 @@ type CountryPickerProps = { function CountryPicker({value, errorText, onInputChange = () => {}}: CountryPickerProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const [isPickerVisible, setIsPickerVisible] = useState(false); const hidePickerModal = () => { @@ -51,10 +48,9 @@ function CountryPicker({value, errorText, onInputChange = () => {}}: CountryPick {!!errorText && ( - )} diff --git a/src/components/CurrencyPicker.tsx b/src/components/CurrencyPicker.tsx index c40c6833bd7b..7deb936a838a 100644 --- a/src/components/CurrencyPicker.tsx +++ b/src/components/CurrencyPicker.tsx @@ -16,7 +16,6 @@ import type {CurrencyListItem} from './CurrencySelectionList/types'; import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; import CurrencySelectionList from './CurrencySelectionList'; -import FormHelpMessage from './FormHelpMessage'; import HeaderWithBackButton from './HeaderWithBackButton'; import MenuItem from './MenuItem'; import MenuItemField from './MenuItem/presets/MenuItemField'; @@ -76,10 +75,9 @@ function CurrencyPicker({label, value, errorText, headerContent, excludeCurrenci {!!errorText && ( - )} diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts index 51500674e706..ab06f3b3654b 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -39,6 +39,7 @@ import MenuItemFieldValue from './leaves/content/MenuItemFieldValue'; import MenuItemLabel from './leaves/content/MenuItemLabel'; import MenuItemTitle from './leaves/content/MenuItemTitle'; import MenuItemIcon from './leaves/leading/MenuItemIcon'; +import MenuItemHelpText from './leaves/MenuItemHelpText'; import MenuItemBrickRoadIndicator from './leaves/trailing/icons/MenuItemBrickRoadIndicator'; import MenuItemChevron from './leaves/trailing/icons/MenuItemChevron'; import MenuItemNewWindowIcon from './leaves/trailing/icons/MenuItemNewWindowIcon'; @@ -62,6 +63,7 @@ const MenuItem = Object.assign(LegacyMenuItem, { Chevron: MenuItemChevron, NewWindowIcon: MenuItemNewWindowIcon, RightLabel: MenuItemRightLabel, + HelpText: MenuItemHelpText, }); export default MenuItem; diff --git a/src/components/MenuItem/leaves/MenuItemHelpText.tsx b/src/components/MenuItem/leaves/MenuItemHelpText.tsx new file mode 100644 index 000000000000..67670944aeba --- /dev/null +++ b/src/components/MenuItem/leaves/MenuItemHelpText.tsx @@ -0,0 +1,29 @@ +import FormHelpMessage from '@components/FormHelpMessage'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +type MenuItemHelpTextProps = { + /** Error or hint text to render under the row. Nothing renders when it is empty */ + message?: string; + + /** Whether the text reads as an error (red) instead of a hint (muted) */ + isError?: boolean; +}; + +/** The trailing help line of a `MenuItem.Root`, rendered under `MenuItem.Row` and inside the row's press target */ +function MenuItemHelpText({message, isError = false}: MenuItemHelpTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemHelpText; diff --git a/src/components/StatePicker/index.tsx b/src/components/StatePicker/index.tsx index 5b628875d7aa..c80d22dd81a7 100644 --- a/src/components/StatePicker/index.tsx +++ b/src/components/StatePicker/index.tsx @@ -1,9 +1,7 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; import type {Option} from '@libs/searchOptions'; @@ -31,7 +29,6 @@ type StatePickerProps = { function StatePicker({value, errorText, onInputChange = () => {}}: StatePickerProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const [isPickerVisible, setIsPickerVisible] = useState(false); const hidePickerModal = () => { @@ -54,10 +51,9 @@ function StatePicker({value, errorText, onInputChange = () => {}}: StatePickerPr {!!errorText && ( - )} diff --git a/src/pages/Debug/ConstantSelector.tsx b/src/pages/Debug/ConstantSelector.tsx index 601c3200b8a0..680bfcdea732 100644 --- a/src/pages/Debug/ConstantSelector.tsx +++ b/src/pages/Debug/ConstantSelector.tsx @@ -1,9 +1,6 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import useThemeStyles from '@hooks/useThemeStyles'; - import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -46,7 +43,6 @@ type ConstantSelectorProps = { // eslint-disable-next-line @typescript-eslint/no-unused-vars function ConstantSelector({formType, policyID, errorText = '', name, value, onInputChange, ref}: ConstantSelectorProps) { - const styles = useThemeStyles(); const fieldValue = (useRoute().params as Record | undefined)?.[name]; useEffect(() => { @@ -79,10 +75,9 @@ function ConstantSelector({formType, policyID, errorText = '', name, value, onIn {!!errorText && ( - )} diff --git a/src/pages/Debug/DateTimeSelector.tsx b/src/pages/Debug/DateTimeSelector.tsx index a7d16489427c..7120d5a52a53 100644 --- a/src/pages/Debug/DateTimeSelector.tsx +++ b/src/pages/Debug/DateTimeSelector.tsx @@ -1,9 +1,6 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import useThemeStyles from '@hooks/useThemeStyles'; - import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -40,7 +37,6 @@ type DateTimeSelectorProps = { // eslint-disable-next-line @typescript-eslint/no-unused-vars function DateTimeSelector({errorText = '', name, value, onInputChange, ref}: DateTimeSelectorProps) { - const styles = useThemeStyles(); const fieldValue = (useRoute().params as Record | undefined)?.[name]; useEffect(() => { @@ -73,10 +69,9 @@ function DateTimeSelector({errorText = '', name, value, onInputChange, ref}: Dat {!!errorText && ( - )} diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index 56ef628b6e45..146fe6a05e98 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -1,4 +1,3 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -7,7 +6,6 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; @@ -36,7 +34,6 @@ type DomainAdminDetailsPageProps = PlatformStackScreenProps{translate('domain.admins.revokeAdminAccess')} - {isCurrentUserPrimaryContact && ( - - )} + {isCurrentUserPrimaryContact && } )} diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index 328030fe8d82..33de39909c57 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -1,6 +1,5 @@ import Button from '@components/Button'; import DecisionModal from '@components/DecisionModal'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; @@ -235,10 +234,9 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { )} {!!lockDomainErrorMessage && ( - )} diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index ed582e47fcb8..7445ec47be89 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -6,7 +6,6 @@ import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import CardPreview from '@components/CardPreview'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import FormHelpMessage from '@components/FormHelpMessage'; import FrozenCardHeader from '@components/FrozenCardHeader'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; @@ -442,8 +441,7 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { name={translate('workspace.card.issueNewCard.limitType')} value={currentCardLimitTypeTranslationKey ? translate(currentCardLimitTypeTranslationKey) : ''} /> - {shouldShowReportLostCardButton && ( diff --git a/src/pages/settings/Wallet/WalletPage/CardDetails.tsx b/src/pages/settings/Wallet/WalletPage/CardDetails.tsx index 28c8c7d41220..b434153bf73c 100644 --- a/src/pages/settings/Wallet/WalletPage/CardDetails.tsx +++ b/src/pages/settings/Wallet/WalletPage/CardDetails.tsx @@ -1,4 +1,3 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -71,13 +70,7 @@ function CardDetails({pan = '', expiration = '', cvv = '', onUpdateAddressPress, name={translate('workspace.card.issueNewCard.limitType')} value={translate(getTranslationKeyForLimitType(limitType))} /> - {!!cardHintText && ( - - )} + {!!cardHintText && } )} {expiration?.length > 0 && ( diff --git a/src/pages/workspace/accounting/dualentry/advanced/DualEntryAdvancedPage.tsx b/src/pages/workspace/accounting/dualentry/advanced/DualEntryAdvancedPage.tsx index 114f6afb64f2..175e651fe3fa 100644 --- a/src/pages/workspace/accounting/dualentry/advanced/DualEntryAdvancedPage.tsx +++ b/src/pages/workspace/accounting/dualentry/advanced/DualEntryAdvancedPage.tsx @@ -1,6 +1,5 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -109,11 +108,7 @@ function DualEntryAdvancedPage({policy}: WithPolicyConnectionsProps) { )} - + diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx index 0deaec33ab9d..ff5e13cd9f28 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx @@ -1,5 +1,4 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -87,11 +86,7 @@ function DualEntryCardAccountPage({policy}: WithPolicyConnectionsProps) { {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( - + )} diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx index 5358201d5798..a0f9947f831e 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx @@ -1,5 +1,4 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -88,11 +87,7 @@ function DualEntryCardProgramAccountPage({policy}: WithPolicyConnectionsProps) { {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( - + )} diff --git a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx index b6a05e3a6c9e..6d586f0dd1a1 100644 --- a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx +++ b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx @@ -1,6 +1,5 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -121,11 +120,7 @@ function SageIntacctAdvancedPage({policy}: WithPolicyProps) { {!!config?.autoSync?.enabled && ( - + )} diff --git a/src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx b/src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx index 7f414bf0959d..d974cd62fc36 100644 --- a/src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx +++ b/src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx @@ -1,6 +1,5 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -124,13 +123,7 @@ function SageIntacctToggleMappingsPage({route}: SageIntacctToggleMappingsPagePro {areSettingsInErrorFields([mappingName], config?.errorFields) && } - {!!translationKeys?.descriptionKey && ( - - )} + {!!translationKeys?.descriptionKey && } diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx index 2ab804d55ee9..a26c558c960e 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx @@ -1,6 +1,5 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -101,13 +100,7 @@ function NetSuiteAdvancedPage({policy}: WithPolicyConnectionsProps) { {areSettingsInErrorFields(item.subscribedSettings, config?.errorFields) && } - {!!item.hintText && ( - - )} + {!!item.hintText && } ); diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx index 53e7844a5417..448c20b63f78 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx @@ -1,9 +1,7 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -29,7 +27,6 @@ type NetSuiteCustomListPickerProps = { function NetSuiteCustomListPicker({value, policyID, errorText, isEditing}: NetSuiteCustomListPickerProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); return ( {!!errorText && ( - )} diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx index 47d06a9e6f67..7b2fcb280549 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx @@ -1,6 +1,5 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -92,13 +91,7 @@ function DynamicQuickbooksDesktopCompanyCardExpenseAccountPage({policy}: WithPol {areSettingsInErrorFields(section.subscribedSettings, qbdConfig?.errorFields) && } - {!!section.hintText && ( - - )} + {!!section.hintText && } ))} diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx index ceca34f405d8..7572551e0eb9 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx @@ -1,5 +1,4 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -124,13 +123,7 @@ function DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage({policy}: W {!!section.brickRoadIndicator && } - {!!section.hintText && ( - - )} + {!!section.hintText && } ))} diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx index 662b73270099..6268e0173163 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx @@ -1,5 +1,4 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -103,13 +102,7 @@ function DynamicQuickbooksCompanyCardExpenseAccountPage({policy}: WithPolicyConn {areSettingsInErrorFields(section.subscribedSettings, qboConfig?.errorFields) && } - {!!section.hintText && ( - - )} + {!!section.hintText && } ))} diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx index 6e595ccf2854..72a976f03840 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx @@ -1,5 +1,4 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -145,13 +144,7 @@ function DynamicQuickbooksOutOfPocketExpenseConfigurationPage({policy}: WithPoli {!!section.brickRoadIndicator && } - {!!section.hintText && ( - - )} + {!!section.hintText && } ))} diff --git a/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx b/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx index d51f5e75bf33..dd029a177c68 100644 --- a/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx +++ b/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx @@ -1,6 +1,5 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -108,11 +107,7 @@ function RilletAdvancedPage({policy}: WithPolicyConnectionsProps) { )} - + diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx index 32346cb572a2..95d51943fdc3 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx @@ -1,5 +1,4 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -87,11 +86,7 @@ function RilletCardAccount({policy}: WithPolicyConnectionsProps) { {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( - + )} diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx index 3270213eab94..f72cbb157e59 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx @@ -1,5 +1,4 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -88,11 +87,7 @@ function RilletCardProgramAccount({policy}: WithPolicyConnectionsProps) { {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( - + )} diff --git a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx index c03eb1cde3dd..f751c71cd9aa 100644 --- a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx +++ b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx @@ -1,6 +1,5 @@ import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; -import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; @@ -136,10 +135,9 @@ function ImportFromFileStep() { {!!shouldShowLayoutNameError && ( - )} diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index 6e82df79ca1f..8562be74479f 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx @@ -2,7 +2,6 @@ import cardScarf from '@assets/images/card-scarf.svg'; import Badge from '@components/Badge'; import DecisionModal from '@components/DecisionModal'; -import FormHelpMessage from '@components/FormHelpMessage'; import FrozenCardHeader from '@components/FrozenCardHeader'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; @@ -438,10 +437,8 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen > {!!canWriteExpensifyCard && } - diff --git a/src/pages/workspace/taxes/TaxValuePicker.tsx b/src/pages/workspace/taxes/TaxValuePicker.tsx index ab57ab99a9e9..ea42a012854a 100644 --- a/src/pages/workspace/taxes/TaxValuePicker.tsx +++ b/src/pages/workspace/taxes/TaxValuePicker.tsx @@ -1,10 +1,8 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; -import useThemeStyles from '@hooks/useThemeStyles'; import {getTaxValueWithPercentage} from '@libs/actions/TaxRate'; import Navigation from '@libs/Navigation/Navigation'; @@ -39,7 +37,6 @@ type TaxValuePickerProps = { function TaxValuePicker({policyID, value, errorText, rightLabel, onInputChange, onPress}: TaxValuePickerProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const previousValue = usePrevious(value); useEffect(() => { @@ -65,10 +62,9 @@ function TaxValuePicker({policyID, value, errorText, rightLabel, onInputChange, {!!errorText && ( - )} diff --git a/src/stories/MenuItemComparison.stories.tsx b/src/stories/MenuItemComparison.stories.tsx index 5fafe3ecbbbe..ae81a9767699 100644 --- a/src/stories/MenuItemComparison.stories.tsx +++ b/src/stories/MenuItemComparison.stories.tsx @@ -5,7 +5,6 @@ import Button from '@components/Button'; import CompactMenuContext from '@components/CompactMenuContext'; import DisplayNames from '@components/DisplayNames'; import type {DisplayNameWithTooltip} from '@components/DisplayNames/types'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation'; @@ -256,10 +255,9 @@ function Comparison() { - } @@ -281,11 +279,7 @@ function Comparison() { name="Limit type" value="Smart limit" /> - + } /> From 5ab422bc42e65b07e6ca2570186ad42ee77321a7 Mon Sep 17 00:00:00 2001 From: yuldashevyusuf Date: Wed, 16 Sep 2026 18:07:47 +0500 Subject: [PATCH 279/485] Keep the current tab when opening an exitTo link from a signed-in validate login --- src/libs/actions/Session/index.ts | 2 +- .../HandleExitToNavigationTests.tsx | 150 ++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/navigation/HandleExitToNavigationTests.tsx diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 81c76b2a9d06..a48f0dbbe516 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -1600,7 +1600,7 @@ function waitForUserSignIn(): Promise { function handleExitToNavigation(exitTo: Route) { waitForUserSignIn().then(() => { Navigation.waitForProtectedRoutes().then(() => { - Navigation.goBack(ROUTES.HOME, {waitForTransition: true}); + Navigation.goBack(navigationRef.canGoBack() ? undefined : ROUTES.HOME, {waitForTransition: true}); Navigation.navigate(exitTo, {waitForTransition: true}); }); }); diff --git a/tests/navigation/HandleExitToNavigationTests.tsx b/tests/navigation/HandleExitToNavigationTests.tsx new file mode 100644 index 000000000000..245f23a85606 --- /dev/null +++ b/tests/navigation/HandleExitToNavigationTests.tsx @@ -0,0 +1,150 @@ +import {act, render} from '@testing-library/react-native'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import {handleExitToNavigation} from '@userActions/Session'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {InitialState} from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import TestNavigationContainer from '../utils/TestNavigationContainer'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@libs/getIsNarrowLayout', () => jest.fn()); + +jest.mock('@pages/inbox/sidebar/NavigationTabBarAvatar'); + +const mockedGetIsNarrowLayout = jest.mocked(getIsNarrowLayout); +const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayout); + +const REPORT_ID = '1'; + +function getRoutesWithTabNavigatorUnderValidateLogin(activeTabIndex: number): InitialState['routes'] { + return [ + { + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + index: activeTabIndex, + routes: [ + {name: SCREENS.HOME}, + { + name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + state: {index: 0, routes: [{name: SCREENS.INBOX}]}, + }, + { + name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, + state: {index: 0, routes: [{name: SCREENS.SEARCH.ROOT}]}, + }, + {name: SCREENS.INSIGHTS}, + {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}, + {name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + ], + }, + }, + {name: SCREENS.VALIDATE_LOGIN}, + ]; +} + +function getTabNavigatorStates() { + return navigationRef.current + ?.getRootState() + .routes.filter((route) => route.name === NAVIGATORS.TAB_NAVIGATOR) + .map((route) => route.state); +} + +function hasValidateLoginRoute() { + return !!navigationRef.current?.getRootState().routes.some((route) => route.name === SCREENS.VALIDATE_LOGIN); +} + +async function runHandleExitToNavigation() { + await act(async () => { + handleExitToNavigation(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + await waitForBatchedUpdates(); + }); +} + +describe('handleExitToNavigation', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockedGetIsNarrowLayout.mockReturnValue(true); + mockedUseResponsiveLayout.mockReturnValue({ + ...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, + shouldUseNarrowLayout: true, + }); + jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(); + await Onyx.set(ONYXKEYS.SESSION, {authToken: 'authToken', accountID: 1}); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await Onyx.clear(); + }); + + it('opens the report inside the Inbox tab when the link is opened from Inbox', async () => { + render( + , + ); + + await runHandleExitToNavigation(); + + expect(hasValidateLoginRoute()).toBe(false); + const tabState = getTabNavigatorStates()?.at(-1); + const focusedTab = tabState?.routes.at(tabState.index ?? 0); + expect(focusedTab?.name).toBe(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); + expect(focusedTab?.state?.routes.at(-1)?.name).toBe(SCREENS.REPORT); + }); + + it('keeps Spend under the report when the link is opened from Spend', async () => { + render( + , + ); + + await runHandleExitToNavigation(); + + expect(hasValidateLoginRoute()).toBe(false); + const tabStates = getTabNavigatorStates(); + const focusedTabState = tabStates?.at(-1); + const focusedTab = focusedTabState?.routes.at(focusedTabState.index ?? 0); + expect(focusedTab?.name).toBe(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); + expect(focusedTab?.state?.routes.at(-1)?.name).toBe(SCREENS.REPORT); + expect(tabStates?.at(0)?.index).toBe(2); + }); + + it('goes back to Home when ValidateLogin is the only route', async () => { + const goBackSpy = jest.spyOn(Navigation, 'goBack'); + render(); + + await runHandleExitToNavigation(); + + expect(goBackSpy).toHaveBeenCalledWith(ROUTES.HOME, { + waitForTransition: true, + }); + }); +}); From d433662a61b9dc8d789e7af3815f160126680232 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 16 Sep 2026 15:34:41 +0200 Subject: [PATCH 280/485] Fix issues with buttons pushed above keyboard --- .../AdvancedFilters/AmountFilterContent.tsx | 25 ++++-- .../ReportFieldFilterContent.tsx | 66 +++++++-------- .../TextInputFilterContent.tsx | 83 +++++++++++-------- .../FilterComponents/BankAccountSelector.tsx | 3 + .../Search/FilterComponents/CardSelector.tsx | 3 + .../Search/FilterComponents/InSelector.tsx | 3 + .../Search/FilterComponents/MultiSelect.tsx | 3 + .../ReportField/ReportFieldList.tsx | 4 +- .../ReportField/ReportFieldText.tsx | 31 ++++++- .../FilterComponents/ReportField/index.tsx | 35 +++++--- .../Search/FilterComponents/SingleSelect.tsx | 3 + .../Search/FilterComponents/UserSelector.tsx | 3 + .../FilterComponents/WorkspaceSelector.tsx | 3 + .../hooks/useShouldFooterBeInsideList.ts | 16 ++++ .../BaseSelectionListWithSections.tsx | 32 +++++-- src/components/SelectionList/types.ts | 7 +- .../SearchAdvancedFiltersContentBase.tsx | 6 -- 17 files changed, 218 insertions(+), 108 deletions(-) create mode 100644 src/components/Search/hooks/useShouldFooterBeInsideList.ts diff --git a/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx index 5593fe3f2e52..8d471dbb07ac 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx @@ -1,6 +1,7 @@ import AmountWithoutCurrencyInput from '@components/AmountWithoutCurrencyInput'; import Button from '@components/Button'; import ScrollView from '@components/ScrollView'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import type {SearchAmountFilterKeys, SearchAmountValues} from '@components/Search/types'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import type {ListItem} from '@components/SelectionList/types'; @@ -157,6 +158,7 @@ function AmountFilterContent({baseFilterKey, value, autoFocus, buttonSize, style const {translate} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const shouldButtonBeInScrollView = useShouldFooterBeInsideList(); const getInitialSelectedAmountModifier = () => { const hasLessThan = !!value?.[CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]; @@ -206,6 +208,18 @@ function AmountFilterContent({baseFilterKey, value, autoFocus, buttonSize, style ]; const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); + const button = ( + + ); + return ( ))} + {shouldButtonBeInScrollView && button} - + {!shouldButtonBeInScrollView && button} ); } diff --git a/src/components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent.tsx index 9172573b83cb..3ad1c58f4b11 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/ReportFieldFilterContent.tsx @@ -3,7 +3,6 @@ import ReportFieldBase from '@components/Search/FilterComponents/ReportField'; import type {ReportFieldHandle} from '@components/Search/FilterComponents/ReportField'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; @@ -25,45 +24,44 @@ type ReportFieldFilterContentProps = { function ReportFieldFilterContent({values, selectedField, size, style, onFieldSelected, onChange}: ReportFieldFilterContentProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const reportFieldRef = useRef(null); const [error, setError] = useState(); - return ( - <> - - {!!selectedField && ( - + ); - onChange(value); - }} - > - - {translate('common.apply')} - - )} - + return ( + ); } diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index cb6b865977e5..60003f81f1d4 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -1,5 +1,7 @@ import Button from '@components/Button'; +import ScrollView from '@components/ScrollView'; import NegatableFilter from '@components/Search/FilterComponents/NegatableFilter'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; import type {ReportFieldTextKey, SearchTextFilterKeys} from '@components/Search/types'; import TextInput from '@components/TextInput'; @@ -43,45 +45,56 @@ function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); const {inputCallbackRef} = useAutoFocusInput(); const error = useTextFilterValidation(baseFilterKey, value); + const shouldButtonBeInScrollView = useShouldFooterBeInsideList(); + + const button = ( + + ); return ( - - { - if (!autoFocus || !isTextInput(ref)) { - return; - } - inputCallbackRef(ref); - }} - placeholder={label} - value={value} - errorText={error} - hasError={!!error} - onChangeText={setValue} - accessibilityLabel={label} - role={CONST.ROLE.PRESENTATION} - containerStyles={[styles.ph5]} - /> - - + + { + if (!autoFocus || !isTextInput(ref)) { + return; + } + inputCallbackRef(ref); + }} + placeholder={label} + value={value} + errorText={error} + hasError={!!error} + onChangeText={setValue} + accessibilityLabel={label} + role={CONST.ROLE.PRESENTATION} + containerStyles={[styles.ph5]} + /> + + {shouldButtonBeInScrollView && button} + + {!shouldButtonBeInScrollView && button} ); } diff --git a/src/components/Search/FilterComponents/BankAccountSelector.tsx b/src/components/Search/FilterComponents/BankAccountSelector.tsx index 4b26b94c6c4b..e0925b4155f1 100644 --- a/src/components/Search/FilterComponents/BankAccountSelector.tsx +++ b/src/components/Search/FilterComponents/BankAccountSelector.tsx @@ -1,6 +1,7 @@ import ActivityIndicator from '@components/ActivityIndicator'; import Icon from '@components/Icon'; import getBankIcon from '@components/Icon/BankIcons'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import type {SearchFilterCommonProps} from '@components/Search/types'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; @@ -47,6 +48,7 @@ type BankAccountFilterItem = { function BankAccountSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: BankAccountSelectorProps) { const theme = useTheme(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -188,6 +190,7 @@ function BankAccountSelector({value = [], selectionListTextInputStyle, selection shouldPreventAutoScrollOnSelect style={selectionListStyle} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> )} diff --git a/src/components/Search/FilterComponents/CardSelector.tsx b/src/components/Search/FilterComponents/CardSelector.tsx index f49bff3b9ce3..83e0fbc7fa77 100644 --- a/src/components/Search/FilterComponents/CardSelector.tsx +++ b/src/components/Search/FilterComponents/CardSelector.tsx @@ -1,5 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import type {SearchFilterCommonProps} from '@components/Search/types'; import CardListItem from '@components/SelectionList/ListItem/CardListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; @@ -34,6 +35,7 @@ type CardSelectorProps = SearchFilterCommonProps; function CardSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: CardSelectorProps) { const theme = useTheme(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -167,6 +169,7 @@ function CardSelector({value = [], selectionListTextInputStyle, selectionListSty shouldPreventAutoScrollOnSelect style={selectionListStyle} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> )} diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index 51479cb7b82e..93f296af0a6c 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -1,4 +1,5 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import type {SearchFilterCommonProps} from '@components/Search/types'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; @@ -49,6 +50,7 @@ function getSelectedOptionData(option: Option & Pick): O function InSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, ready = true, footer, onChange}: InSelectorProps) { const {translate, dateFnsLocale} = useLocalize(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {convertToDisplayString} = useCurrencyListActions(); const personalDetails = usePersonalDetails(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -264,6 +266,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle shouldShowTextInput style={selectionListStyle} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> ); diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 24db44ce0860..9983c52d11ba 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -1,4 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import type {SearchFilterCommonProps} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; @@ -67,6 +68,7 @@ function MultiSelect({ onChange, }: MultiSelectProps) { const theme = useTheme(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -148,6 +150,7 @@ function MultiSelect({ textInputOptions={textInputOptions} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> )} diff --git a/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx b/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx index d47eb9e9c2c7..743f56f58e64 100644 --- a/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx +++ b/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx @@ -7,10 +7,11 @@ import React from 'react'; type ReportFieldListProps = { field: PolicyReportField; value: string | undefined; + footer?: React.ReactNode; onChange: (newValue: string | undefined) => void; }; -function ReportFieldList({field, value, onChange}: ReportFieldListProps) { +function ReportFieldList({field, value, footer, onChange}: ReportFieldListProps) { const items = field.values.map((fieldValue) => ({ value: fieldValue, text: fieldValue, @@ -24,6 +25,7 @@ function ReportFieldList({field, value, onChange}: ReportFieldListProps) { allowDeselect onChange={(item) => onChange(item?.value)} hasHeader + footer={footer} /> ); } diff --git a/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx b/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx index 64bae7615f46..b39928f1ad4a 100644 --- a/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx +++ b/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx @@ -1,3 +1,5 @@ +import ScrollView from '@components/ScrollView'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; import type {ReportFieldTextKey} from '@components/Search/types'; import TextInput from '@components/TextInput'; @@ -13,15 +15,18 @@ type ReportFieldTextProps = { filterKey: ReportFieldTextKey; field: PolicyReportField; value: string | undefined; + footer?: React.ReactNode; + shouldUseScrollView?: boolean; onChange: (newValue: string) => void; onError: (error: string | undefined) => void; }; -function ReportFieldText({filterKey, field, value, onChange, onError}: ReportFieldTextProps) { +function ReportFieldText({filterKey, field, value, footer, shouldUseScrollView, onChange, onError}: ReportFieldTextProps) { const styles = useThemeStyles(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const error = useTextFilterValidation(filterKey, value, onError); - return ( + const input = ( ); + + if (!shouldUseScrollView) { + return ( + <> + {input} + {footer} + + ); + } + + return ( + <> + + {input} + {shouldFooterBeInsideList && footer} + + {!shouldFooterBeInsideList && footer} + + ); } export default ReportFieldText; diff --git a/src/components/Search/FilterComponents/ReportField/index.tsx b/src/components/Search/FilterComponents/ReportField/index.tsx index fea784abc2ea..76bb84cd6e2c 100644 --- a/src/components/Search/FilterComponents/ReportField/index.tsx +++ b/src/components/Search/FilterComponents/ReportField/index.tsx @@ -1,3 +1,4 @@ +import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import ScrollView from '@components/ScrollView'; @@ -42,6 +43,8 @@ type ReportFieldBaseProps = { selectedField: PolicyReportField | null; hasFeed: boolean; style?: StyleProp; + footer?: React.ReactNode; + shouldUseScrollView?: boolean; onFieldSelected: (field: PolicyReportField | null) => void; onError: (error: string | undefined) => void; }; @@ -50,6 +53,8 @@ type SelectedReportFieldProps = { ref: React.Ref; field: PolicyReportField; value: string | undefined; + footer?: React.ReactNode; + shouldUseScrollView?: boolean; onError: (error: string | undefined) => void; }; @@ -71,7 +76,7 @@ function getFilterKey(fieldName: string) { return `${CONST.SEARCH.REPORT_FIELD.DEFAULT_PREFIX}${suffix}` as const; } -function SelectedReportField({ref, field, value: initialValue, onError}: SelectedReportFieldProps) { +function SelectedReportField({ref, field, value: initialValue, footer, shouldUseScrollView, onError}: SelectedReportFieldProps) { const [value, setValue] = useState(initialValue); const fieldType = field.type as Exclude, typeof CONST.REPORT_FIELD_TYPES.FORMULA | typeof CONST.REPORT_FIELD_TYPES.DATE>; const filterKey = getFilterKey(field.name); @@ -97,6 +102,8 @@ function SelectedReportField({ref, field, value: initialValue, onError}: Selecte filterKey={filterKey} field={field} value={value} + footer={footer} + shouldUseScrollView={shouldUseScrollView} onChange={setValue} onError={onError} /> @@ -167,7 +174,7 @@ function SelectedDateReportField({ref, field, value: initialValue, selectedDateM ); } -function ReportFieldBase({ref, values: initialValues = {}, selectedField, hasFeed, style, onFieldSelected, onError}: ReportFieldBaseProps) { +function ReportFieldBase({ref, values: initialValues = {}, selectedField, hasFeed, style, footer, shouldUseScrollView, onFieldSelected, onError}: ReportFieldBaseProps) { const {translate, localeCompare, dateFnsLocale} = useLocalize(); const styles = useThemeStyles(); const policyReportFieldsSelector = (policies: OnyxCollection) => createAllPolicyReportFieldsSelector(policies, localeCompare); @@ -235,6 +242,9 @@ function ReportFieldBase({ref, values: initialValues = {}, selectedField, hasFee })); if (selectedField) { + // A list field pads the footer through its SelectionList, the other field types have no such owner and are padded here. + const paddedFooter = selectedField.type === CONST.REPORT_FIELD_TYPES.LIST ? footer : {footer}; + return ( <> {!selectedDateModifier && ( @@ -245,19 +255,24 @@ function ReportFieldBase({ref, values: initialValues = {}, selectedField, hasFee /> )} {selectedField.type === CONST.REPORT_FIELD_TYPES.DATE ? ( - + <> + + {paddedFooter} + ) : ( )} diff --git a/src/components/Search/FilterComponents/SingleSelect.tsx b/src/components/Search/FilterComponents/SingleSelect.tsx index d46ac97fb381..4980d03f8a44 100644 --- a/src/components/Search/FilterComponents/SingleSelect.tsx +++ b/src/components/Search/FilterComponents/SingleSelect.tsx @@ -1,3 +1,4 @@ +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import type {SearchFilterCommonProps} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -64,6 +65,7 @@ function SingleSelectImpl({ onChange, }: SingleSelectProps) { const {translate} = useLocalize(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const [selectedItem, setSelectedItem] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -151,6 +153,7 @@ function SingleSelectImpl({ initiallyFocusedItemKey={isSearchable ? value?.value : undefined} shouldShowLoadingPlaceholder={!noResultsFound} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> diff --git a/src/components/Search/FilterComponents/UserSelector.tsx b/src/components/Search/FilterComponents/UserSelector.tsx index b6e516f665dd..2081716fd92b 100644 --- a/src/components/Search/FilterComponents/UserSelector.tsx +++ b/src/components/Search/FilterComponents/UserSelector.tsx @@ -1,4 +1,5 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import type {Filter, SearchFilterCommonProps} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; @@ -31,6 +32,7 @@ type UserSelectorProps = SearchFilterCommonProps & { function UserSelector({value = [], isNegatable, policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, ready = true, footer, onChange}: UserSelectorProps) { const styles = useThemeStyles(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {translate} = useLocalize(); const personalDetails = usePersonalDetails(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -146,6 +148,7 @@ function UserSelector({value = [], isNegatable, policyID, selectionListTextInput shouldShowLoadingPlaceholder={!areOptionsInitialized || !ready} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> ); diff --git a/src/components/Search/FilterComponents/WorkspaceSelector.tsx b/src/components/Search/FilterComponents/WorkspaceSelector.tsx index c7ce27e27941..0ca36eba4d9d 100644 --- a/src/components/Search/FilterComponents/WorkspaceSelector.tsx +++ b/src/components/Search/FilterComponents/WorkspaceSelector.tsx @@ -1,6 +1,7 @@ import ActivityIndicator from '@components/ActivityIndicator'; import Checkbox from '@components/Checkbox'; import {PressableWithoutFeedback} from '@components/Pressable'; +import useShouldFooterBeInsideList from '@components/Search/hooks/useShouldFooterBeInsideList'; import type {SearchFilterCommonProps} from '@components/Search/types'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; import type {ListItem} from '@components/SelectionList/ListItem/types'; @@ -103,6 +104,7 @@ function SectionHeader({ function WorkspaceSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, ready = true, footer, onChange}: WorkspaceSelectorProps) { const {isOffline} = useNetwork(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -231,6 +233,7 @@ function WorkspaceSelector({value = [], selectionListTextInputStyle, selectionLi shouldPreventAutoScrollOnSelect style={selectionListStyle} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> )} diff --git a/src/components/Search/hooks/useShouldFooterBeInsideList.ts b/src/components/Search/hooks/useShouldFooterBeInsideList.ts new file mode 100644 index 000000000000..6e3acd0826e0 --- /dev/null +++ b/src/components/Search/hooks/useShouldFooterBeInsideList.ts @@ -0,0 +1,16 @@ +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useKeyboardState from '@hooks/useKeyboardState'; + +/** + * Filters that render a selection list show their confirm button as the list footer. In landscape mode the open keyboard + * leaves very little vertical space, so a footer fixed to the bottom eats most of what is left for the list. + * In that case the footer is moved into the list, so it scrolls with the data instead. + */ +function useShouldFooterBeInsideList(): boolean { + const isInLandscapeMode = useIsInLandscapeMode(); + const {isKeyboardActive} = useKeyboardState(); + + return isInLandscapeMode && isKeyboardActive; +} + +export default useShouldFooterBeInsideList; diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index c341c3cafb9a..1e3783c999ff 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -92,6 +92,7 @@ function BaseSelectionListWithSectionsImpl({ shouldHighlightSelectedItem, shouldDisableHoverStyle, selectionButtonPosition, + shouldFooterBeInsideList = false, setShouldDisableHoverStyle = () => {}, }: SelectionListWithSectionsProps) { const styles = useThemeStyles(); @@ -335,6 +336,17 @@ function BaseSelectionListWithSectionsImpl({ } }; + // Footer renders nothing when there is no footer content + const footer = ( + + footerContent={footerContent} + addBottomSafeAreaPadding={addBottomSafeAreaPadding} + /> + ); + + const shouldShowEmptyState = itemsCount === 0 && (shouldShowLoadingPlaceholder || shouldShowListEmptyContent); + const isFooterInsideList = shouldFooterBeInsideList && !shouldShowEmptyState; + return ( {textInputComponent()} {customHeaderContent} - {itemsCount === 0 && (shouldShowLoadingPlaceholder || shouldShowListEmptyContent) ? ( + {shouldShowEmptyState ? ( + {listFooterContent} + {footer} + + ) : ( + listFooterContent + ) + } ListFooterComponentStyle={style?.listFooterContentStyle} style={style?.listStyle} contentContainerStyle={style?.contentContainerStyle} maintainVisibleContentPosition={{disabled: true}} /> )} - {!!footerContent && ( - - footerContent={footerContent} - addBottomSafeAreaPadding={addBottomSafeAreaPadding} - /> - )} + {!isFooterInsideList && footer} ); } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 6e59ce000ad4..5970132886a9 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -21,6 +21,10 @@ type BaseSelectionListProps = { onSelectRow: (item: TItem) => void; canSelectMultiple?: boolean; footerContent?: React.ReactNode; + + /** Whether to place the footer in the list so it scrolls with data instead of being fixed to the bottom */ + shouldFooterBeInsideList?: boolean; + listFooterContent?: React.JSX.Element | null | undefined; shouldShowLoadingPlaceholder?: boolean; rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactElement | null | undefined) | ReactElement | null; @@ -139,9 +143,6 @@ type SelectionListProps = Partial & /** Custom accessibility label for the select all checkbox, providing context about what is being selected */ selectAllAccessibilityLabel?: string; - - /** Whether to place the footer (custom footer content or the confirm button) in the list so it scrolls with data instead of being fixed to the bottom */ - shouldFooterBeInsideList?: boolean; }; type SelectionListStyle = { diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx index 2af9a9233425..8dd2f3749375 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx @@ -6,7 +6,6 @@ import SearchAdvancedFiltersContent from '@components/Search/FilterComponents/Ad import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; import {useSearchQueryContext} from '@components/Search/SearchContext'; -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -48,7 +47,6 @@ function SearchAdvancedFiltersContentBase() { const route = useRoute>(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const isInLandscapeMode = useIsInLandscapeMode(); const filterKey = route.params.filterKey; const shouldApplyFilterChangeDirectly = !!route.params.applyDirectly; @@ -85,8 +83,6 @@ function SearchAdvancedFiltersContentBase() { validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO || validFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG; - const shouldDisableKeyboardAvoidingView = isInLandscapeMode && isFilterWithSelectionList; - const buttonText = shouldApplyFilterChangeDirectly ? translate('common.apply') : undefined; const getCollapsibleHeaderOffset = () => { @@ -109,8 +105,6 @@ function SearchAdvancedFiltersContentBase() { offlineIndicatorStyle={styles.mtAuto} includeSafeAreaPaddingBottom shouldEnableMaxHeight - // In landscape mode we don't want to push confirm button above the keyboard for filters with selection list - shouldEnableKeyboardAvoidingView={!shouldDisableKeyboardAvoidingView} > {({didScreenTransitionEnd}) => validFilterKey ? ( From f25c2746cfa190ac5d15f2c3d49bd95ce8e87608 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 16 Sep 2026 16:02:05 +0200 Subject: [PATCH 281/485] refactor: migrate to the new API --- .../MenuItem/leaves/MenuItemHelpText.tsx | 4 +- .../Wallet/ExpensifyCardPage/index.tsx | 14 ++--- ...namicWorkspaceExpensifyCardDetailsPage.tsx | 14 ++--- .../WorkspaceCardSettingsPage.tsx | 57 +++++++++++-------- 4 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/components/MenuItem/leaves/MenuItemHelpText.tsx b/src/components/MenuItem/leaves/MenuItemHelpText.tsx index 67670944aeba..b21419c0ed30 100644 --- a/src/components/MenuItem/leaves/MenuItemHelpText.tsx +++ b/src/components/MenuItem/leaves/MenuItemHelpText.tsx @@ -2,11 +2,13 @@ import FormHelpMessage from '@components/FormHelpMessage'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {ReactNode} from 'react'; + import React from 'react'; type MenuItemHelpTextProps = { /** Error or hint text to render under the row. Nothing renders when it is empty */ - message?: string; + message?: string | ReactNode; /** Whether the text reads as an error (red) instead of a hint (muted) */ isError?: boolean; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 7445ec47be89..4e5ab5534869 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -429,13 +429,13 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { accessibilityLabel={spendRulesSummary.join('. ')} /> )} - + + + {!!remainingLimitHint && } + )} - + + + {!!remainingLimitHint && } + - Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS_FREQUENCY.getRoute(policyID))} - hintText={ - isSettlementFrequencyBlocked ? ( - <> - {translate('workspace.expensifyCard.settlementFrequencyInfo')}{' '} - - {translate('common.learnMore')} - - . - - ) : ( - monthlySettlementDateText - ) + Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS_FREQUENCY.getRoute(policyID))) } - /> + > + + {settlementFrequency !== CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY && } + + + {translate('workspace.expensifyCard.settlementFrequencyInfo')}{' '} + + {translate('common.learnMore')} + + . + + ) : ( + monthlySettlementDateText + ) + } + /> + {shouldShowCashbackToggle && ( Date: Wed, 16 Sep 2026 19:16:59 +0500 Subject: [PATCH 282/485] Remove ValidateLogin directly instead of through goBack when opening an exitTo link --- src/libs/actions/Session/index.ts | 6 +++++- .../HandleExitToNavigationTests.tsx | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index a48f0dbbe516..e18fb59bdc3d 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -1600,7 +1600,11 @@ function waitForUserSignIn(): Promise { function handleExitToNavigation(exitTo: Route) { waitForUserSignIn().then(() => { Navigation.waitForProtectedRoutes().then(() => { - Navigation.goBack(navigationRef.canGoBack() ? undefined : ROUTES.HOME, {waitForTransition: true}); + if (navigationRef.canGoBack()) { + Navigation.pop(navigationRef.getRootState().key); + } else { + Navigation.goBack(ROUTES.HOME, {waitForTransition: true}); + } Navigation.navigate(exitTo, {waitForTransition: true}); }); }); diff --git a/tests/navigation/HandleExitToNavigationTests.tsx b/tests/navigation/HandleExitToNavigationTests.tsx index 245f23a85606..8993bf471b4c 100644 --- a/tests/navigation/HandleExitToNavigationTests.tsx +++ b/tests/navigation/HandleExitToNavigationTests.tsx @@ -137,6 +137,27 @@ describe('handleExitToNavigation', () => { expect(tabStates?.at(0)?.index).toBe(2); }); + it('removes ValidateLogin even when a pop to sidebar is pending', async () => { + render( + , + ); + Navigation.setShouldPopToSidebar(true); + + await runHandleExitToNavigation(); + + expect(hasValidateLoginRoute()).toBe(false); + const tabState = getTabNavigatorStates()?.at(-1); + const focusedTab = tabState?.routes.at(tabState.index ?? 0); + expect(focusedTab?.name).toBe(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); + expect(focusedTab?.state?.routes.at(-1)?.name).toBe(SCREENS.REPORT); + Navigation.setShouldPopToSidebar(false); + }); + it('goes back to Home when ValidateLogin is the only route', async () => { const goBackSpy = jest.spyOn(Navigation, 'goBack'); render(); From c3ec04b8d38b3cc59e3f554bd3388aeb6a76b7ad Mon Sep 17 00:00:00 2001 From: Uzaifm127 Date: Wed, 16 Sep 2026 20:58:21 +0530 Subject: [PATCH 283/485] upgrade: bump @shopify/flash-list to 2.3.2 --- package-lock.json | 8 ++--- package.json | 2 +- ...fix-horizontal-height-normalization.patch} | 0 ...t+2.3.2+002+skip-layout-when-hidden.patch} | 12 +++---- ...ix-inverted-scroll-direction-on-web.patch} | 8 ++--- ...+004+fix-inverted-first-item-offset.patch} | 8 ++--- ...ding-children-blocking-measurements.patch} | 12 +++---- ...2.3.2+006+fix-inverted-mvcp-android.patch} | 12 +++---- ...07+fix-scroll-anchor-unmount-on-ios.patch} | 16 +++++----- ...ash-list+2.3.2+008+increase-timeout.patch} | 4 +-- ...+009+ignore-stale-viewholder-layout.patch} | 7 ++-- ...2.3.2+010+fix-web-subpixel-rounding.patch} | 0 ....3.2+011+sort-for-natural-DOM-order.patch} | 22 ++++++------- ...012+fix-scrollbar-oscillation-crash.patch} | 12 +++---- ...3.2+013+improve-scroll-key-handling.patch} | 14 ++++---- ...list+2.3.2+014+external-window-size.patch} | 14 ++++---- ...sh-list+2.3.2+015+mvcp-header-aware.patch} | 24 +++++++------- ...nore-stale-viewholder-render-layout.patch} | 21 +++++++----- patches/@shopify/flash-list/details.md | 32 +++++++++---------- 19 files changed, 118 insertions(+), 110 deletions(-) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch => @shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch} (100%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch => @shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch} (90%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch => @shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch} (97%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch => @shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch} (91%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch => @shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch} (91%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch => @shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch} (95%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch => @shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch} (87%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+008+increase-timeout.patch => @shopify+flash-list+2.3.2+008+increase-timeout.patch} (90%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch => @shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch} (89%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch => @shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch} (100%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch => @shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch} (97%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch => @shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch} (95%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch => @shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch} (97%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+014+external-window-size.patch => @shopify+flash-list+2.3.2+014+external-window-size.patch} (95%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch => @shopify+flash-list+2.3.2+015+mvcp-header-aware.patch} (97%) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch => @shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch} (89%) diff --git a/package-lock.json b/package-lock.json index 46b750fad273..f7132642fb2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/core": "10.73.0", "@sentry/react-native": "8.25.0", - "@shopify/flash-list": "2.3.0", + "@shopify/flash-list": "2.3.2", "@shopify/react-native-skia": "^2.4.18", "@ua/react-native-airship": "26.5.0", "array.prototype.tosorted": "^1.1.4", @@ -17435,9 +17435,9 @@ } }, "node_modules/@shopify/flash-list": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.0.tgz", - "integrity": "sha512-DR7VuN8KJHTYj9zv1/IhpqrMBMQyeeW/DCWCbVQAAkWhHrc6ylIbXOY+qK93CuHABV+dNHXK/3V6p4wCSW/+wA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.2.tgz", + "integrity": "sha512-vQnd0y0Ag11yzS30llaeCrtIU3xYTMe7KEOP3dveLImnVjQEUw6BEg1+Ztja6aODlVNz1BpvxtlQ5eZGxiLwKw==", "license": "MIT", "peerDependencies": { "@babel/runtime": "*", diff --git a/package.json b/package.json index a64e9dde6c0d..aa779562b8c2 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/core": "10.73.0", "@sentry/react-native": "8.25.0", - "@shopify/flash-list": "2.3.0", + "@shopify/flash-list": "2.3.2", "@shopify/react-native-skia": "^2.4.18", "@ua/react-native-airship": "26.5.0", "array.prototype.tosorted": "^1.1.4", diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch similarity index 100% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch similarity index 90% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch index eae8317799bf..881c1fc1d325 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8b75322..dd2d3bc 100644 +index 6799c8a..4324707 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -74,6 +74,10 @@ const RecyclerViewComponent = (props, ref) => { +@@ -75,6 +75,10 @@ const RecyclerViewComponent = (props, ref) => { if (internalViewRef.current && firstChildViewRef.current) { // Measure the outer container size and inner container layout const outerViewSize = measureParentSize(internalViewRef.current); @@ -13,7 +13,7 @@ index 8b75322..dd2d3bc 100644 const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current); containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, -@@ -103,6 +107,10 @@ const RecyclerViewComponent = (props, ref) => { +@@ -104,6 +108,10 @@ const RecyclerViewComponent = (props, ref) => { if (pendingChildIds.size > 0) { return; } @@ -25,10 +25,10 @@ index 8b75322..dd2d3bc 100644 const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index)); // comapre height with stored layout diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 70f856a..9908674 100644 +index 08724d1..bc5b0ae 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -165,7 +165,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -175,7 +175,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe const { horizontal } = recyclerViewManager.props; if (scrollViewRef.current) { // Adjust offset for RTL layouts in horizontal mode @@ -37,7 +37,7 @@ index 70f856a..9908674 100644 // eslint-disable-next-line no-param-reassign offset = adjustOffsetForRTL(offset, recyclerViewManager.getChildContainerDimensions().width, recyclerViewManager.getWindowSize().width) + -@@ -235,6 +235,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -245,6 +245,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe * Returns a Promise that resolves when the scroll is complete. */ scrollToIndex: ({ index, animated, viewPosition, viewOffset, }) => { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch similarity index 97% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch index edb436a356b4..355071c7f514 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index dd2d3bc..d7a3d84 100644 +index 4324707..3deea98 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js @@ -2,8 +2,8 @@ @@ -13,7 +13,7 @@ index dd2d3bc..d7a3d84 100644 import { ErrorMessages } from "../errors/ErrorMessages"; import { WarningMessages } from "../errors/WarningMessages"; import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; -@@ -66,6 +66,66 @@ const RecyclerViewComponent = (props, ref) => { +@@ -67,6 +67,66 @@ const RecyclerViewComponent = (props, ref) => { // Hook to detect when scrolling reaches list bounds const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); const isHorizontalRTL = I18nManager.isRTL && horizontal; @@ -81,7 +81,7 @@ index dd2d3bc..d7a3d84 100644 * Initialize the RecyclerView by measuring and setting up the window size * This effect runs when the component mounts or when layout changes diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 34722d4..ea801d2 100644 +index 3c25e7e..42a901f 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx @@ -5,6 +5,7 @@ @@ -100,7 +100,7 @@ index 34722d4..ea801d2 100644 } from "react-native"; import { FlashListRef } from "../FlashListRef"; -@@ -158,6 +160,88 @@ const RecyclerViewComponent = ( +@@ -159,6 +161,88 @@ const RecyclerViewComponent = ( const isHorizontalRTL = I18nManager.isRTL && horizontal; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch similarity index 91% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch index 98bac124be64..bc0a9a496b1e 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index d7a3d84..ffcdad8 100644 +index 3deea98..bcaa951 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -142,9 +142,11 @@ const RecyclerViewComponent = (props, ref) => { +@@ -143,9 +143,11 @@ const RecyclerViewComponent = (props, ref) => { containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, // so its x/y directly gives the first item offset. @@ -18,10 +18,10 @@ index d7a3d84..ffcdad8 100644 recyclerViewManager.updateLayoutParams({ width: horizontal ? outerViewSize.width : firstChildViewLayout.width, diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index ea801d2..8a7deff 100644 +index 42a901f..a6db0d9 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -259,9 +259,11 @@ const RecyclerViewComponent = ( +@@ -260,9 +260,11 @@ const RecyclerViewComponent = ( // firstChildViewLayout is already relative to the outer container, // so its x/y directly gives the first item offset. diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch similarity index 91% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch index 6b96845c5875..4cd36710dae9 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ffcdad8..ee42f63 100644 +index bcaa951..88ca8b1 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -166,9 +166,6 @@ const RecyclerViewComponent = (props, ref) => { +@@ -167,9 +167,6 @@ const RecyclerViewComponent = (props, ref) => { // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { var _a, _b; @@ -12,7 +12,7 @@ index ffcdad8..ee42f63 100644 if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { return; -@@ -196,8 +193,17 @@ const RecyclerViewComponent = (props, ref) => { +@@ -197,8 +194,17 @@ const RecyclerViewComponent = (props, ref) => { } if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) && !hasExceededMaxRendersWithoutCommit) { @@ -33,10 +33,10 @@ index ffcdad8..ee42f63 100644 else { (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 8a7deff..b2bd67a 100644 +index a6db0d9..c09ba9b 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -287,9 +287,6 @@ const RecyclerViewComponent = ( +@@ -288,9 +288,6 @@ const RecyclerViewComponent = ( */ // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { @@ -46,7 +46,7 @@ index 8a7deff..b2bd67a 100644 const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { const layout = measureItemLayout( viewHolderRef.current!, -@@ -323,8 +320,17 @@ const RecyclerViewComponent = ( +@@ -324,8 +321,17 @@ const RecyclerViewComponent = ( recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && !hasExceededMaxRendersWithoutCommit ) { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch similarity index 95% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch index 48a9b893bbfd..cd6f646d1bb3 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 70f856a..52546f7 100644 +index bc5b0ae..fe87b9b 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -1,5 +1,5 @@ @@ -38,8 +38,8 @@ index 70f856a..52546f7 100644 + } // If we have a tracked first visible item, maintain its position if (firstVisibleItemKey.current) { - const currentIndexOfFirstVisibleItem = (_a = recyclerViewManager -@@ -115,10 +122,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + // Try engaged indices first (fast O(n) over rendered items), then +@@ -125,10 +132,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe !pauseOffsetCorrection.current && !recyclerViewManager.animationOptimizationsEnabled) { // console.log("diff", diff, firstVisibleItemKey.current); @@ -73,7 +73,7 @@ index 70f856a..52546f7 100644 else { const scrollToParams = horizontal ? { -@@ -162,6 +190,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -172,6 +200,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe * Handles RTL layouts and first item offset adjustments. */ scrollToOffset: ({ offset, animated, skipFirstItemOffset = true, }) => { @@ -87,7 +87,7 @@ index 70f856a..52546f7 100644 const { horizontal } = recyclerViewManager.props; if (scrollViewRef.current) { // Adjust offset for RTL layouts in horizontal mode -@@ -205,6 +240,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -215,6 +250,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe * Scrolls to the end of the list. */ scrollToEnd: async ({ animated } = {}) => { @@ -101,7 +101,7 @@ index 70f856a..52546f7 100644 const { data } = recyclerViewManager.props; if (data && data.length > 0) { const lastIndex = data.length - 1; -@@ -238,6 +280,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -248,6 +290,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe if (!recyclerViewManager.hasLayout()) { return Promise.resolve(); } diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch similarity index 87% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch index 7f1700a548d9..460796111dfd 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ee42f63..4e8d8c0 100644 +index 88ca8b1..085c819 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -380,16 +380,15 @@ const RecyclerViewComponent = (props, ref) => { +@@ -382,16 +382,15 @@ const RecyclerViewComponent = (props, ref) => { } return onScrollHandler; }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); @@ -19,9 +19,9 @@ index ee42f63..4e8d8c0 100644 - }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]); + }, [maintainVisibleContentPosition]); const shouldRenderFromBottom = recyclerViewManager.getDataLength() > 0 && - ((_d = maintainVisibleContentPosition === null || maintainVisibleContentPosition === void 0 ? void 0 : maintainVisibleContentPosition.startRenderingFromBottom) !== null && _d !== void 0 ? _d : false); + ((_e = maintainVisibleContentPosition === null || maintainVisibleContentPosition === void 0 ? void 0 : maintainVisibleContentPosition.startRenderingFromBottom) !== null && _e !== void 0 ? _e : false); // Create view for measuring bounded size -@@ -401,11 +399,11 @@ const RecyclerViewComponent = (props, ref) => { +@@ -403,11 +402,11 @@ const RecyclerViewComponent = (props, ref) => { }, ref: firstChildViewRef })); }, [horizontal, stickyHeaderOffset]); const scrollAnchor = useMemo(() => { @@ -36,10 +36,10 @@ index ee42f63..4e8d8c0 100644 // Render the main RecyclerView structure return (React.createElement(RecyclerViewContextProvider, { value: recyclerViewContext }, diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index b2bd67a..d4bf02d 100644 +index c09ba9b..974b485 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -572,18 +572,15 @@ const RecyclerViewComponent = ( +@@ -575,18 +575,15 @@ const RecyclerViewComponent = ( return onScrollHandler; }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); @@ -60,7 +60,7 @@ index b2bd67a..d4bf02d 100644 const shouldRenderFromBottom = recyclerViewManager.getDataLength() > 0 && -@@ -604,7 +600,7 @@ const RecyclerViewComponent = ( +@@ -607,7 +604,7 @@ const RecyclerViewComponent = ( }, [horizontal, stickyHeaderOffset]); const scrollAnchor = useMemo(() => { @@ -69,7 +69,7 @@ index b2bd67a..d4bf02d 100644 return ( ( +@@ -616,7 +613,7 @@ const RecyclerViewComponent = ( ); } return null; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+008+increase-timeout.patch similarity index 90% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+008+increase-timeout.patch index a76bba73cc2d..73152813f0e6 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+008+increase-timeout.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 51b6f8c..d4ca252 100644 +index fe87b9b..b90f898 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -507,7 +507,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -520,7 +520,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe setTimeout(() => { recyclerViewManager.isInitialScrollComplete = true; pauseOffsetCorrection.current = false; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch similarity index 89% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch index 928c0d6c28a6..c55eb5a37083 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch @@ -1,7 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index 085c819..b8ab103 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -316,7 +316,10 @@ function RecyclerView(props) { +@@ -317,7 +317,10 @@ const RecyclerViewComponent = (props, ref) => { */ const validateItemSize = useCallback((index, size) => { var _a, _b, _c, _d; @@ -14,9 +15,11 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js const height = Math.max(Math.min(layout.height, (_c = layout.maxHeight) !== null && _c !== void 0 ? _c : Infinity), (_d = layout.minHeight) !== null && _d !== void 0 ? _d : 0); if (areDimensionsNotEqual(width, size.width) || diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 974b485..ea42f73 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -465,6 +465,9 @@ function RecyclerView(props: RecyclerViewProps) { +@@ -466,7 +466,10 @@ const RecyclerViewComponent = ( + */ const validateItemSize = useCallback( (index: number, size: RVDimension) => { - const layout = recyclerViewManager.getLayout(index); diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch similarity index 100% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch similarity index 97% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch index 321a812fa2cc..84d4c41bd393 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch @@ -34,10 +34,10 @@ index 08b83f3..05a64b1 100644 * Scrolls to a specific item in the list. * diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 4e53325..ef4daf2 100644 +index b8ab103..27d9f87 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => { +@@ -59,7 +59,7 @@ const RecyclerViewComponent = (props, ref) => { const refHolder = useMemo(() => new Map(), []); // Initialize core RecyclerView manager and content offset management const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props); @@ -46,7 +46,7 @@ index 4e53325..ef4daf2 100644 // Initialize view holder collection ref const viewHolderCollectionRef = useRef(null); // Hook to handle list loading -@@ -238,12 +238,19 @@ const RecyclerViewComponent = (props, ref) => { +@@ -239,12 +239,19 @@ const RecyclerViewComponent = (props, ref) => { return; } if (isMomentumEnd) { @@ -66,7 +66,7 @@ index 4e53325..ef4daf2 100644 // Update scroll position and trigger re-render if needed if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) { setRenderId((prev) => prev + 1); -@@ -266,6 +273,9 @@ const RecyclerViewComponent = (props, ref) => { +@@ -267,6 +274,9 @@ const RecyclerViewComponent = (props, ref) => { computeFirstVisibleIndexForOffsetCorrection, horizontal, isHorizontalRTL, @@ -76,7 +76,7 @@ index 4e53325..ef4daf2 100644 recyclerViewManager, velocityTracker, ]); -@@ -461,7 +471,7 @@ const RecyclerViewComponent = (props, ref) => { +@@ -463,7 +473,7 @@ const RecyclerViewComponent = (props, ref) => { recyclerViewManager.animationOptimizationsEnabled = false; }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout() ? recyclerViewManager.getChildContainerDimensions() @@ -398,7 +398,7 @@ index 62d55cd..b715484 100644 //# sourceMappingURL=useRecyclerViewController.d.ts.map \ No newline at end of file diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 165b080..18e59ce 100644 +index b90f898..48e0bdc 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -25,6 +25,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe @@ -423,7 +423,7 @@ index 165b080..18e59ce 100644 const pendingAndroidInvertedRafId = useRef(null); const skipNextAndroidInvertedCorrection = useRef(false); const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); -@@ -180,6 +195,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -190,6 +205,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe updateScrollOffsetWithCallback, computeFirstVisibleIndexForOffsetCorrection, ]); @@ -457,7 +457,7 @@ index 165b080..18e59ce 100644 const handlerMethods = useMemo(() => { return { get props() { -@@ -271,6 +313,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -281,6 +323,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe animated, }); }, @@ -469,7 +469,7 @@ index 165b080..18e59ce 100644 /** * Scrolls to a specific index in the list. * Supports viewPosition and viewOffset for precise positioning. -@@ -292,6 +339,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -302,6 +349,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe // Pause the scroll offset adjustments pauseOffsetCorrection.current = true; recyclerViewManager.setOffsetProjectionEnabled(false); @@ -481,7 +481,7 @@ index 165b080..18e59ce 100644 const getFinalOffset = () => { const layout = recyclerViewManager.getLayout(index); const offset = horizontal ? layout.x : layout.y; -@@ -496,6 +548,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -506,6 +558,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe setTimeout, isUnmounted, updateScrollOffsetWithCallback, @@ -489,7 +489,7 @@ index 165b080..18e59ce 100644 ]); const applyInitialScrollIndex = useCallback(() => { var _a, _b, _c; -@@ -550,6 +603,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -560,6 +613,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch similarity index 95% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch index 03a1dbf1249d..8271a322e1b1 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch @@ -16,7 +16,7 @@ index 12375d9..bfc3ee2 100644 /** * LinearLayoutManager implementation that arranges items in a single row or column. * Supports both horizontal and vertical layouts with dynamic item sizing. -@@ -10,6 +19,8 @@ +@@ -10,6 +19,8 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { this.hasSize = false; /** Height of the tallest item */ this.tallestItemHeight = 0; @@ -25,7 +25,7 @@ index 12375d9..bfc3ee2 100644 this.boundedSize = this.horizontal ? params.windowSize.height : params.windowSize.width; -@@ -23,9 +34,15 @@ +@@ -23,9 +34,15 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { const prevHorizontal = this.horizontal; super.updateLayoutParams(params); const oldBoundedSize = this.boundedSize; @@ -42,10 +42,11 @@ index 12375d9..bfc3ee2 100644 if (oldBoundedSize !== this.boundedSize || prevHorizontal !== this.horizontal) { if (this.layouts.length > 0) { -@@ -36,6 +53,81 @@ +@@ -35,6 +52,81 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { + } } } - /** ++ /** + * Web only. Stops the re-layout loop caused by a scrollbar that keeps showing and hiding. + * + * The list measures its width without the scrollbar, so each toggle changes the width, which @@ -120,7 +121,6 @@ index 12375d9..bfc3ee2 100644 + ]; + return smaller; + } -+ /** + /** * Processes layout information for items, updating their dimensions. * For horizontal layouts, also normalizes heights of items. - * @param layoutInfo Array of layout information for items diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch similarity index 97% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch index c11114fa1de0..de2c04c71180 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -index fa786bf..586014c 100644 +index 5e90a5c..cd99ea1 100644 --- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts @@ -127,10 +127,12 @@ export interface FlashListProps extends Omit visibleIndices", visibleIndices); @@ -71,7 +71,7 @@ index 3b69234..a9474c1 100644 if (this.hasRenderedProgressively) { this.isFirstLayoutComplete = true; } -@@ -327,9 +354,13 @@ export class RecyclerViewManager { +@@ -326,9 +353,13 @@ export class RecyclerViewManager { // If everything is measured then render stack will be in sync. The buffer items will get rendered in the next update // triggered by the useOnLoad hook. !this.hasRenderedProgressively && @@ -89,7 +89,7 @@ index 3b69234..a9474c1 100644 } getItemType(index) { diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 18e59ce..40bdddb 100644 +index 48e0bdc..dddd8ba 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -25,6 +25,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe @@ -103,7 +103,7 @@ index 18e59ce..40bdddb 100644 // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in // flight. Cleared exactly once on `isMomentumEnd` via // `notifyProgrammaticScrollSettled`. -@@ -566,18 +570,55 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -576,18 +580,55 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe }, 500); pauseOffsetCorrection.current = true; const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+014+external-window-size.patch similarity index 95% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+014+external-window-size.patch index 971a05fa078b..2a0e801fef9e 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+014+external-window-size.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -index fa786bf..014eb62 100644 +index cd99ea1..d926c81 100644 --- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts @@ -99,6 +99,15 @@ export interface FlashListProps extends Omit { - var _a, _b, _c, _d; + var _a, _b, _c, _d, _e; // Destructure props and initialize refs - const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, ...rest } = props; + const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, overrideWindowSize, ...rest } = props; const [renderTimeTracker] = useState(() => new RenderTimeTracker()); renderTimeTracker.startTracking(); // Sticky header config -@@ -147,12 +147,15 @@ const RecyclerViewComponent = (props, ref) => { +@@ -148,12 +148,15 @@ const RecyclerViewComponent = (props, ref) => { : horizontal ? firstChildViewLayout.x : firstChildViewLayout.y; @@ -50,7 +50,7 @@ index ef4daf2..063fd1a 100644 ? firstItemOffset - recyclerViewManager.getChildContainerDimensions().width diff --git a/node_modules/@shopify/flash-list/src/FlashListProps.ts b/node_modules/@shopify/flash-list/src/FlashListProps.ts -index 76dd0c8..5a5c0f5 100644 +index 1dba06c..9531d92 100644 --- a/node_modules/@shopify/flash-list/src/FlashListProps.ts +++ b/node_modules/@shopify/flash-list/src/FlashListProps.ts @@ -160,6 +160,16 @@ export interface FlashListProps @@ -71,7 +71,7 @@ index 76dd0c8..5a5c0f5 100644 * Draw distance for advanced rendering (in dp/px) */ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index bc27739..a7829d5 100644 +index ea42f73..4d6b24e 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx @@ -90,6 +90,7 @@ const RecyclerViewComponent = ( @@ -82,7 +82,7 @@ index bc27739..a7829d5 100644 ...rest } = props; -@@ -265,13 +266,17 @@ const RecyclerViewComponent = ( +@@ -266,13 +267,17 @@ const RecyclerViewComponent = ( ? firstChildViewLayout.x : firstChildViewLayout.y; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch similarity index 97% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch index 5d59a58de2ff..52147e9f2820 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 063fd1a..c37ff3a 100644 +index c00fc99..a65347b 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -349,8 +349,27 @@ const RecyclerViewComponent = (props, ref) => { +@@ -350,8 +350,27 @@ const RecyclerViewComponent = (props, ref) => { recyclerViewContext.layout(); } }, [recyclerViewContext, recyclerViewManager]); @@ -32,7 +32,7 @@ index 063fd1a..c37ff3a 100644 recyclerViewManager.getDataLength() > 0) { parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.markChildLayoutAsPending(recyclerViewId); diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 40bdddb..c505e59 100644 +index dddd8ba..4c3862c 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -51,6 +51,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe @@ -71,7 +71,7 @@ index 40bdddb..c505e59 100644 } } }, [recyclerViewManager]); -@@ -128,15 +143,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -138,15 +153,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe : undefined); if (currentIndexOfFirstVisibleItem !== undefined && currentIndexOfFirstVisibleItem >= 0) { @@ -95,7 +95,7 @@ index 40bdddb..c505e59 100644 if (diff !== 0 && !pauseOffsetCorrection.current && !recyclerViewManager.animationOptimizationsEnabled) { -@@ -147,13 +168,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -157,13 +178,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe } else if (useAndroidInvertedFallback) { if (!shouldSkipAndroidInvertedCorrection) { @@ -114,7 +114,7 @@ index 40bdddb..c505e59 100644 animated: false, }; if (pendingAndroidInvertedRafId.current !== null) { -@@ -169,17 +193,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -179,17 +203,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe else { const scrollToParams = horizontal ? { @@ -160,7 +160,7 @@ index 4d7a945..26133d6 100644 * Creates the footer component with optional styling. */ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index a7829d5..ec5d7b7 100644 +index 4d6b24e..3782805 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx @@ -16,6 +16,7 @@ import React, { @@ -171,7 +171,7 @@ index a7829d5..ec5d7b7 100644 NativeScrollEvent, NativeSyntheticEvent, Platform, -@@ -501,6 +502,31 @@ const RecyclerViewComponent = ( +@@ -502,6 +503,31 @@ const RecyclerViewComponent = ( [recyclerViewContext, recyclerViewManager] ); @@ -203,7 +203,7 @@ index a7829d5..ec5d7b7 100644 // Get secondary props and components const { refreshControl, -@@ -509,7 +535,7 @@ const RecyclerViewComponent = ( +@@ -510,7 +536,7 @@ const RecyclerViewComponent = ( renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, @@ -213,7 +213,7 @@ index a7829d5..ec5d7b7 100644 if ( !recyclerViewManager.getIsFirstLayoutComplete() && diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx -index 3012391..7375752 100644 +index 3689830..1308c15 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx @@ -57,6 +57,10 @@ export function useRecyclerViewController( @@ -255,7 +255,7 @@ index 3012391..7375752 100644 } } }, [recyclerViewManager]); -@@ -156,15 +174,23 @@ export function useRecyclerViewController( +@@ -166,15 +184,23 @@ export function useRecyclerViewController( currentIndexOfFirstVisibleItem !== undefined && currentIndexOfFirstVisibleItem >= 0 ) { @@ -281,7 +281,7 @@ index 3012391..7375752 100644 if ( diff !== 0 && !pauseOffsetCorrection.current && -@@ -175,20 +201,27 @@ export function useRecyclerViewController( +@@ -185,20 +211,27 @@ export function useRecyclerViewController( // console.log("scrollBy", diff); scrollAnchorRef.current?.scrollBy(diff); } else { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch similarity index 89% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch index 572d6b9790bf..6eb1fa0f749c 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch @@ -1,7 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index a65347b..562f92c 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -467,7 +467,7 @@ function RecyclerView(props) { +@@ -469,7 +469,7 @@ const RecyclerViewComponent = (props, ref) => { isHorizontalRTL && viewToMeasureBoundedSize, renderHeader, !isHorizontalRTL && viewToMeasureBoundedSize, @@ -11,9 +12,10 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js return 0; } diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts +index fd2ff94..f617bd6 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -@@ -19,7 +19,7 @@ export interface ViewHolderCollectionProps { +@@ -19,7 +19,7 @@ export interface ViewHolderCollectionProps { index: number; }>; /** Function to get layout information for a specific index */ @@ -23,9 +25,10 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollec viewHolderCollectionRef: React.Ref; /** Map to store refs for each ViewHolder instance */ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +index e66d406..3d3e4b0 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -290,6 +290,13 @@ export function ViewHolderCollection(props) { +@@ -290,6 +290,13 @@ export const ViewHolderCollection = (props) => { return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout && hasData && renderEntriesRef.current.map(([reactKey, { index }]) => { @@ -39,7 +42,7 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollec const item = data[index]; // Suppress separators for items in the last row to prevent // height mismatch. The last data item has no separator (no -@@ -298,7 +305,7 @@ export function ViewHolderCollection(props) { +@@ -298,7 +305,7 @@ export const ViewHolderCollection = (props) => { ? data[index + 1] : undefined; return (React.createElement(ViewHolder, { key: reactKey, index: index, item: item, trailingItem: trailingItem, layout: { @@ -49,9 +52,10 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollec }))); }; diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 3782805..cac633c 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -705,7 +705,7 @@ function RecyclerView(props: RecyclerViewProps) { +@@ -709,7 +709,7 @@ const RecyclerViewComponent = ( data={data} horizontal={horizontal} renderStack={recyclerViewManager.getRenderStack()} @@ -61,9 +65,10 @@ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) { return 0; diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx +index a652beb..5af6016 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx -@@ -23,7 +23,7 @@ export interface ViewHolderCollectionProps { +@@ -23,7 +23,7 @@ export interface ViewHolderCollectionProps { /** Map of indices to React keys for each rendered item */ renderStack: Map; /** Function to get layout information for a specific index */ @@ -72,7 +77,7 @@ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollect /** Ref to control layout updates from parent components */ viewHolderCollectionRef: React.Ref; /** Map to store refs for each ViewHolder instance */ -@@ -176,6 +176,13 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) { +@@ -176,6 +176,13 @@ export const ViewHolderCollection = ( {containerLayout && hasData && Array.from(renderStack.entries(), ([reactKey, { index }]) => { @@ -86,7 +91,7 @@ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollect const item = data[index]; // Suppress separators for items in the last row to prevent // height mismatch. The last data item has no separator (no -@@ -192,7 +199,7 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) { +@@ -192,7 +199,7 @@ export const ViewHolderCollection = ( item={item} trailingItem={trailingItem} layout={{ diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 3e4eeab058c3..cf53dd448f9e 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -1,6 +1,6 @@ # `@shopify/flash-list` patches -### [@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch](@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch) +### [@shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch](@shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch) - Reason: Fixes height normalization in horizontal FlashList when items change. `LinearLayoutManager.normalizeLayoutHeights` had three issues: 1. **Screen resize / item shrink**: When items shrink, `tallestItemHeight` was updated prematurely, causing the next cycle to skip re-normalization. Fixed by resetting tallest item tracking when `targetMinHeight === 0` so the next repaint re-detects the tallest item. @@ -10,7 +10,7 @@ - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/81566 -### [@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch) +### [@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch) - Reason: Prevents FlashList from losing its render state when a navigation stack hides the parent container with `display: none`. Four guards in total — two in `RecyclerView` to skip layout processing while hidden, and two in `useRecyclerViewController` to make scroll methods safe while hidden: 1. **First `useLayoutEffect`** in `RecyclerView` (measures parent container): After calling `measureParentSize()`, if both width and height are 0, return early before calling `updateLayoutParams()` or updating `containerViewSizeRef`. This preserves the last known valid window size and prevents the layout manager from receiving zero dimensions. @@ -23,35 +23,35 @@ - E/App issue: https://github.com/Expensify/App/issues/83976 (original), https://github.com/Expensify/App/issues/90756 (scroll-while-hidden follow-up) - PR introducing patch: https://github.com/Expensify/App/pull/84887 -### [@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch) +### [@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch) - Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 -### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch) +### [@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch) - Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. - Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2300 - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 -### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch) +### [@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch) - Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 -### [@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch) +### [@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch) - Reason: Fixes `maintainVisibleContentPosition` not working on Android for inverted lists when items are prepended (e.g. new messages arriving, or `useFlashListScrollKey` switching from sliced to full data). FlashList's offset correction uses a `ScrollAnchor` component — an invisible absolutely-positioned element whose `top` changes to trigger the native `maintainVisibleContentPosition` on the ScrollView. On Android, where inversion uses `rotate: 180deg` (vs `scaleY: -1` on iOS), this mechanism silently fails: the anchor position changes but the native ScrollView does not adjust its scroll offset. The fix detects the specific case (`inverted && Platform.OS === 'android' && hasDataChanged`) and bypasses `ScrollAnchor` in favor of a deferred `scrollTo` via `requestAnimationFrame`, which fires after the native layout has committed the new content size. Non-inverted lists, iOS, web, and layout-only corrections (no data change) are unaffected and continue using the original code paths. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 -### [@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch](@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch) +### [@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch](@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch) - Reason: Fixes a scroll position reset on iOS when `maintainVisibleContentPosition.disabled` toggles from `true` to `false` (e.g. when `shouldMaintainVisibleContentPosition` changes based on scroll offset). Root cause: `ScrollAnchor` was conditionally rendered based on `shouldMaintainVisibleContentPosition()`. When MVCP was disabled, the anchor unmounted, which made the native Fabric `_firstVisibleView` weak-ref become nil. When MVCP was re-enabled, the anchor remounted at `top: 1,000,000` (its initial position), but `_prevFirstVisibleFrame` was stale at `1,000,000 + X` from the prior anchor instance. `_adjustForMaintainVisibleContentPosition` then computed `deltaY = 0 - (1,000,000 + X)` — a massive negative offset — causing the list to jump to the start. The fix decouples anchor lifetime from the `disabled` flag: `ScrollAnchor` is now always mounted (and `maintainVisibleContentPositionInternal` always non-null) whenever `maintainVisibleContentPosition` prop is defined. The `disabled` flag continues to gate JS-level `scrollBy` corrections in `applyOffsetCorrection` (via `shouldMaintainVisibleContentPosition()`), so the anchor stays in place when MVCP is logically off — the native side always has a live `_firstVisibleView` and a fresh `_prevFirstVisibleFrame` to diff against. - Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. @@ -59,7 +59,7 @@ - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/88923 -### [@shopify+flash-list+2.3.0+008+increase-timeout.patch](@shopify+flash-list+2.3.0+008+increase-timeout.patch) +### [@shopify+flash-list+2.3.2+008+increase-timeout.patch](@shopify+flash-list+2.3.2+008+increase-timeout.patch) - Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Bumped to 500 ms. - Files changed: `dist/recyclerview/hooks/useRecyclerViewController.js` only. @@ -67,7 +67,7 @@ - E/App issue: https://github.com/Expensify/App/issues/89768 - PR introducing patch: https://github.com/Expensify/App/pull/90218 -### [@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch) +### [@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch) - Reason: Prevents stale `ViewHolder.onLayout` callbacks from crashing FlashList after the list data/layout table has changed. `validateItemSize` previously read the stored layout with `recyclerViewManager.getLayout(index)`, which throws when the callback's render-time index is no longer present in the layout manager. The patch uses `recyclerViewManager.tryGetLayout(index)` and returns early when the layout is missing, so obsolete measurements are ignored while current indexes continue through the existing width/height comparison. - Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. @@ -75,7 +75,7 @@ - E/App issue: https://github.com/Expensify/App/issues/89933 - PR introducing patch: https://github.com/Expensify/App/pull/91248 -### [@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch](@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch) +### [@shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch](@shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch) - Reason: Fixes a "Maximum update depth exceeded" infinite render loop on web (mostly Windows with fractional display scaling). `roundOffPixel` on web was a no-op, so subpixel drift in the child container's `getBoundingClientRect()` width re-triggered `ViewHolderCollection`'s `[fixedContainerSize]` layout effect on every measurement. The patch implements `roundOffPixel` to snap to the device-pixel grid (`Math.round(value * devicePixelRatio) / devicePixelRatio`), matching native `PixelRatio.roundToNearestPixel`. Two measurements that paint the same physical pixel now collapse to the same JS value, breaking the loop. - Files changed: `dist/recyclerview/utils/measureLayout.web.js` only. @@ -84,7 +84,7 @@ - Sentry: https://expensify.sentry.io/issues/APP-DQ2 - PR introducing patch: https://github.com/Expensify/App/pull/91799 -### [@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch) +### [@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch) - Reason: Fixes scrambled DOM order in virtualized list items on web. FlashList uses `position: absolute` to position items, so visual order is determined by CSS `top`/`left` values rather than DOM order. Due to recycling (reusing ViewHolder components for different data items), the DOM order reflects Map insertion order rather than data index order. This causes three web-specific issues: @@ -130,7 +130,7 @@ - E/App issue: https://github.com/Expensify/App/issues/86126 - PR introducing patch: https://github.com/Expensify/App/pull/85825 -### [@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch](@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch) +### [@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch](@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch) - Reason: Fixes a "Maximum update depth exceeded" (#185) infinite render loop on web with classic (non-overlay) scrollbars — i.e. Windows/Linux Chrome and macOS with "Always show scroll bars". @@ -159,7 +159,7 @@ - E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263, https://github.com/Expensify/App/issues/95719 - PR introducing patch: https://github.com/Expensify/App/pull/92520 (hardened for #95719) -### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch) +### [@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch) - Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`). Six changes: 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. @@ -173,7 +173,7 @@ - E/App issue: https://github.com/Expensify/App/issues/92152 - PR introducing patch: https://github.com/Expensify/App/pull/93403 -### [@shopify+flash-list+2.3.0+014+external-window-size.patch](@shopify+flash-list+2.3.0+014+external-window-size.patch) +### [@shopify+flash-list+2.3.2+014+external-window-size.patch](@shopify+flash-list+2.3.2+014+external-window-size.patch) - Reason: Adds an **`overrideWindowSize`** prop that lets a list declare its visible window (`{width, height}`) instead of deriving it from `measureParentSize(internalViewRef)`. Needed for an *externally-driven* list — one whose `renderScrollComponent` is a non-scrolling `View` that grows to the full content height and receives synthetic scroll events from a parent scroller. Without this, FlashList measures the outer container (as tall as all content) as its viewport and renders every row, defeating virtualization. The change is minimal: `measureParentSize` is still assigned to `outerViewSize` and used for `containerViewSizeRef` (layout-change detection) and the 0×0 hidden-guard from patch 002; only the `windowSize` fed to `updateLayoutParams` is `overrideWindowSize ?? outerViewSize`. Fully backward compatible — when the prop is unset, `windowSize === outerViewSize` and behavior is byte-identical. Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table (`ExternalScrollFlashListTable`), which windows its rows against the unified list's vertical scroll. - Files changed: `src/FlashListProps.ts`, `src/recyclerview/RecyclerView.tsx`, `dist/FlashListProps.d.ts`, `dist/recyclerview/RecyclerView.js`. @@ -181,7 +181,7 @@ - E/App issue: https://github.com/Expensify/App/issues/91425 - PR introducing patch: https://github.com/Expensify/App/pull/91422 -### [@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch) +### [@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch) - Reason: Makes `maintainVisibleContentPosition` aware of the `ListHeaderComponent`. Item layouts are header-relative, so MVCP was blind to the header in two symmetric ways: 1. **Header resize was never corrected**: when the header changes height after layout (e.g. a nested virtualized table settling from estimated to measured row heights, ~400px on a 207-row table), every data item shifts on screen but no tracked `x`/`y` changes — the anchored item (e.g. a deep-linked report action positioned via `initialScrollIndex`) drifts out of the viewport with no correction. Fixed by capturing `firstItemOffset` alongside the anchor layout (`firstVisibleItemFirstItemOffset`) and including its delta in the correction diff. On the `ScrollAnchor.scrollBy` path (iOS/Android) the full diff is applied; on the `scrollTo` fallback paths (web, Android inverted) only the layout diff is added, because `getAbsoluteLastScrollOffset()` already reflects the current `firstItemOffset` (the tracker's relative offset only resyncs on scroll events), so the header delta is already embedded in it. @@ -193,7 +193,7 @@ - E/App issue: https://github.com/Expensify/App/issues/91425 - PR introducing patch: https://github.com/Expensify/App/pull/91422 -### [@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch](@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch) +### [@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch](@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch) - Reason: Prevents an `index out of bounds, not enough layouts` crash thrown while `ViewHolderCollection` renders. This is the render-path sibling of patch `009`, which only guarded the `validateItemSize` measurement callback. The crash originates in upstream flash-list and reproduces on **every platform** (native crash: `APP-8PG`), not just web. The render stack (`RenderStackManager.keyMap`, returned by `RecyclerViewManager.getRenderStack()`) can hold an entry whose stored `index` exceeds the current `layouts` length when the list `data` shrinks between renders (e.g. deleting a report action, IOU actions being filtered once transactions load, or a Concierge draft being removed). This is a timing gap inside flash-list's own update pipeline: on a data shrink `LayoutManager.modifyLayout` truncates `this.layouts` synchronously (`getLayoutCount()` drops immediately), but the render stack is pruned of the now-out-of-bounds keys only later, when `RenderStackManager.sync()` runs. Any render committed in that gap iterates a `keyMap` still carrying a pre-shrink `index` against the already-shortened `layouts`, so the unguarded `getLayout(index)` wired at `RecyclerView` → `LayoutManager.getLayout` throws. Upstream already guards this same staleness on the measurement path — `modifyLayout` filters stale `layoutInfo` with the comment _"layoutInfo may contain stale indices from ViewHolders that were rendered before the data shrunk"_ — but left the render path unguarded. The patch wires `ViewHolderCollection`'s `getLayout` prop to the bounds-safe `recyclerViewManager.tryGetLayout(index)` and skips (returns `null` for) any render entry whose layout is `undefined`, so a stale index is dropped for that render instead of crashing. Because `keyMap`/`LayoutManager` are shared, platform-agnostic state, the guard applies on both render branches — web's `renderEntriesRef.current.map` and native's `Array.from(renderStack.entries())`. Patch `011` (which introduces web's `renderEntriesRef` copy) only carries the index forward; it is not the source of the stale index. - Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/ViewHolderCollection.tsx`, and their `dist` counterparts (`dist/recyclerview/RecyclerView.js`, `dist/recyclerview/ViewHolderCollection.js`, `dist/recyclerview/ViewHolderCollection.d.ts`). From 7c80bac93235d2c8a6c5b464aff4aec55723927a Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 17 Sep 2026 00:28:02 +0700 Subject: [PATCH 284/485] Revert initial focus on None option in Xero --- .../accounting/certinia/export/CertiniaDefaultVendorPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx index c0f44d6c9b4b..ff06db94f24e 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx @@ -81,7 +81,7 @@ function CertiniaDefaultVendorPage({policy}: WithPolicyConnectionsProps) { data={dataOptions} onSelectRow={selectVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : exportConfig?.vendorAccount} + initiallyFocusedOptionKey={exportConfig?.vendorAccount} onBackButtonPress={() => Navigation.goBack(backPath)} title="workspace.accounting.defaultVendor" listEmptyContent={listEmptyContent} From 2148b59df2e1a623d102fa2cce005b6d866fcc8f Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 17 Sep 2026 00:28:10 +0700 Subject: [PATCH 285/485] Revert initial focus on None option in DualEntry --- .../dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx index a5d455420eb5..d756b29002fd 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx @@ -96,7 +96,7 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr listEmptyContent={listEmptyContent} onSelectRow={selectDefaultVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : defaultCompanyCardVendorID} + initiallyFocusedOptionKey={defaultCompanyCardVendorID} onBackButtonPress={() => Navigation.goBack(backPath)} connectionName={CONST.POLICY.CONNECTIONS.NAME.DUALENTRY} pendingAction={settingsPendingAction([CONST.DUALENTRY_CONFIG.DEFAULT_VENDORID], dualentryConfig?.pendingFields)} From dc70458d97bba464b2b504593afda0322bd5e44d Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 17 Sep 2026 00:28:19 +0700 Subject: [PATCH 286/485] Revert initial focus on None option in Sage Intacct --- .../intacct/export/DynamicSageIntacctDefaultVendorPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx index f48c047dbba1..a9a0a87a538c 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx @@ -130,7 +130,7 @@ function DynamicSageIntacctDefaultVendorPage() { displayName="SageIntacctDefaultVendorPage" data={vendorSelectorOptions ?? []} onSelectRow={updateDefaultVendor} - initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : vendorSelectorOptions.find((mode) => mode.isSelected)?.keyForList} + initiallyFocusedOptionKey={vendorSelectorOptions.find((mode) => mode.isSelected)?.keyForList} headerContent={listHeaderComponent} onBackButtonPress={goBack} title="workspace.sageIntacct.defaultVendor" From f8e1234a67c27824e8720dbd37f835b7944427a7 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 17 Sep 2026 00:28:25 +0700 Subject: [PATCH 287/485] Revert initial focus on None option in NetSuite --- .../export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx index 7afa09c0ad5b..75298c35f884 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx @@ -57,10 +57,7 @@ function DynamicNetSuiteExportExpensesVendorSelectPage({policy}: WithPolicyConne [shouldShowClearOption, clearOption, vendorOptions], ); - const initiallyFocusedOptionKey = useMemo( - () => (shouldShowClearOption ? clearOption.keyForList : netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList), - [shouldShowClearOption, clearOption.keyForList, netsuiteVendorOptions], - ); + const initiallyFocusedOptionKey = useMemo(() => netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteVendorOptions]); const goBack = useCallback(() => { Navigation.goBack(backPath); From 07041a56ccd8d8ca08036912d41e613958543326 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 17 Sep 2026 00:28:32 +0700 Subject: [PATCH 288/485] Revert initial focus on None option in QBD --- .../QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx index c27e8ad8fb65..bf952cc7e288 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx @@ -96,7 +96,7 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP data={data} onSelectRow={selectVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : data.find((mode) => mode.isSelected)?.keyForList} + initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.QBD} onBackButtonPress={() => Navigation.goBack()} From 05edff8deee8663fb93c6e3c974aa94f7602245e Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 17 Sep 2026 00:28:37 +0700 Subject: [PATCH 289/485] Revert initial focus on None option in QBO --- .../qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx index 1abd71c8c33e..24909db88a40 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx @@ -111,7 +111,7 @@ function QuickbooksNonReimbursableVendorSelectPage({policy, configKey, updateVen data={data} onSelectRow={selectVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : data.find((mode) => mode.isSelected)?.keyForList} + initiallyFocusedOptionKey={data.find((mode) => mode.isSelected)?.keyForList} listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.QBO} onBackButtonPress={() => Navigation.goBack()} From 84bfec26e9bce4f6b63e02390b62b63a3c7f6ae6 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 17 Sep 2026 00:28:42 +0700 Subject: [PATCH 290/485] Revert initial focus on None option in Rillet --- .../rillet/export/RilletDefaultCompanyCardVendorPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx index d66233326698..560b686872ee 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx @@ -98,7 +98,7 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps listEmptyContent={listEmptyContent} onSelectRow={selectDefaultVendor} shouldSingleExecuteRowSelect - initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : defaultCompanyCardVendorID} + initiallyFocusedOptionKey={defaultCompanyCardVendorID} onBackButtonPress={() => Navigation.goBack(backPath)} connectionName={CONST.POLICY.CONNECTIONS.NAME.RILLET} pendingAction={settingsPendingAction([CONST.RILLET_CONFIG.DEFAULT_VENDORID], rilletConfig?.pendingFields)} From 92aaa9b20493dc798650209379badfdfbaa40a4e Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 17 Sep 2026 00:28:48 +0700 Subject: [PATCH 291/485] Revert initial focus on None option in Xero --- .../DynamicXeroNonReimbursableDefaultContactSelectPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 5126c056a317..2213aa2eb5c8 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -171,7 +171,7 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy value: searchText, onChangeText: setSearchText, }} - initiallyFocusedOptionKey={shouldShowClearOption ? clearOption.keyForList : data.find((item) => item.isSelected)?.keyForList} + initiallyFocusedOptionKey={data.find((item) => item.isSelected)?.keyForList} listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.XERO} onBackButtonPress={goBack} From e6573ba8e33ccff2cfc7759707234a1fdffc0024 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Wed, 16 Sep 2026 23:40:39 +0000 Subject: [PATCH 292/485] Fix stale-hold identity, sort cancellation, and stale row interaction - Cancel the sort hold on recentSearchHash, which ignores sortBy/sortOrder, so the sort that starts the hold no longer cancels it. - Decide isDifferentSearch by comparing the held search's key against the current one, instead of testing that a search key exists at all (the data-type fallback made that true for every Expenses and Reports query). - Make the held layer inert while stale, since its rows read the newer query's hash and snapshot from the Search contexts. --- src/pages/Search/SearchPage.tsx | 23 ++++++++----- src/pages/Search/SearchPageNarrow/index.tsx | 38 ++++++++++++++------- src/pages/Search/SearchPageWide.tsx | 9 ++++- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 9f32caeb8207..c7b9d9520a6e 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -21,6 +21,7 @@ import {searchInServer} from '@libs/actions/Report'; import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; +import type {SearchKey} from '@libs/SearchUIUtils'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -78,9 +79,11 @@ function SearchPage({route}: SearchPageProps) { const [isSorting, setIsSorting] = useState(false); // Sorting keeps the previous results on screen while the re-sorted ones load, so changing the search has to cancel - // it or those rows render under the new query. Adjusted during rendering because searchResults consumes it below. - const previousQueryHash = usePrevious(currentSearchQueryJSON?.hash); - if (isSorting && previousQueryHash !== currentSearchQueryJSON?.hash) { + // it or those rows render under the new query. Compared on recentSearchHash, which ignores sortBy and sortOrder: + // sorting navigates to a new query of its own, and the primary hash would cancel the hold it just started. + // Adjusted during rendering because searchResults consumes it below. + const previousRecentSearchHash = usePrevious(currentSearchQueryJSON?.recentSearchHash); + if (isSorting && previousRecentSearchHash !== currentSearchQueryJSON?.recentSearchHash) { setIsSorting(false); } @@ -121,13 +124,13 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(false); }, [currentSearchResults?.isLoading, isSorting, prevIsLoading]); - const [lastResolvedSearch, setLastResolvedSearch] = useState<{queryJSON: SearchQueryJSON; searchResults: SearchResults} | undefined>(undefined); + const [lastResolvedSearch, setLastResolvedSearch] = useState<{queryJSON: SearchQueryJSON; searchResults: SearchResults; searchKey: SearchKey | undefined} | undefined>(undefined); // Keying the results area on the requested query would mount it with no data, since a filter builds a query that // has never been cached. isCurrentSearchResolved, not a hash comparison: a response folds sort defaults into its own hash. const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { - setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); + setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults, searchKey: currentSearchKey}); } // A slow query would otherwise hold the previous results up indefinitely, and the wide layout has no loading bar. @@ -153,9 +156,11 @@ function SearchPage({route}: SearchPageProps) { const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; // A sidebar item or saved search asks for a different search, so its results area starts from the skeleton rather - // than showing rows from the query the user just left. Sidebar items are the suggested searches, so they resolve a - // currentSearchKey; saved searches carry a name. A filter refinement matches neither. - const isDifferentSearch = !!currentSearchKey || !!route.params.name; + // than showing rows from the query the user just left. The search key names the suggested search or saved search a + // query belongs to, so refining the held search keeps its key while jumping elsewhere changes it. Compared against + // the held search rather than the previous render, which would match again after one render and resume the hold + // mid-load. + const isDifferentSearch = !!lastResolvedSearch && lastResolvedSearch.searchKey !== currentSearchKey; const shouldHoldLastResolvedSearch = !isSearchResolvedForCurrentQuery && !!lastResolvedSearch && !hasStaleHoldTimedOut && !isDifferentSearch; const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; @@ -194,6 +199,7 @@ function SearchPage({route}: SearchPageProps) { searchResults={searchResults} contentQueryJSON={contentQueryJSON} contentSearchResults={contentSearchResults} + isContentStale={shouldHoldLastResolvedSearch} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} onSortPressedCallback={onSortPressedCallback} searchOverlayContent={searchOverlayContent} @@ -207,6 +213,7 @@ function SearchPage({route}: SearchPageProps) { searchResults={searchResults} contentQueryJSON={contentQueryJSON} contentSearchResults={contentSearchResults} + isContentStale={shouldHoldLastResolvedSearch} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} handleSearchAction={handleSearchAction} onSortPressedCallback={onSortPressedCallback} diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index ffd02be9f9f3..b5a935986e01 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -60,6 +60,9 @@ type SearchPageNarrowProps = { contentQueryJSON?: SearchQueryJSON; contentSearchResults: SearchResults | undefined; + /** True while the area holds a resolved query's results under a newer one that is still loading. */ + isContentStale: boolean; + isMobileSelectionModeEnabled: boolean; onSortPressedCallback: () => void; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ @@ -79,6 +82,7 @@ function SearchPageNarrow({ searchResults, contentQueryJSON, contentSearchResults, + isContentStale, isMobileSelectionModeEnabled, onSortPressedCallback, searchOverlayContent, @@ -325,18 +329,23 @@ function SearchPageNarrow({ {useStaticRendering && ( <> {isInteractive && ( - + + + )} {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( @@ -354,11 +363,14 @@ function SearchPageNarrow({ {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} {/* Keyed on the resolved query, so this only remounts once the new results arrive. Absolutely - filled so it never shares the parent's column layout with the layer it replaces. */} + filled so it never shares the parent's column layout with the layer it replaces. + Held rows read the newer query's hash and snapshot from the Search contexts, so their + actions would target the wrong search — inert until the results they belong to are current. */} {shouldShowLoadingSkeleton ? ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 1aef46671fc9..b8605393dc31 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -43,6 +43,9 @@ type SearchPageWideProps = { contentQueryJSON?: SearchQueryJSON; contentSearchResults: OnyxEntry; + /** True while the area holds a resolved query's results under a newer one that is still loading. */ + isContentStale: boolean; + isMobileSelectionModeEnabled: boolean; handleSearchAction: (value: SearchParams | string) => void; onSortPressedCallback: () => void; @@ -58,6 +61,7 @@ function SearchPageWide({ searchResults, contentQueryJSON, contentSearchResults, + isContentStale, isMobileSelectionModeEnabled, handleSearchAction, onSortPressedCallback, @@ -142,11 +146,14 @@ function SearchPageWide({ {/* skipEntering keeps the delayed fade off the very first mount, so opening Search cold paints immediately. */} {/* Keyed on the resolved query, so this only remounts once the new results arrive. Absolutely - filled so it never shares the parent's column layout with the layer it replaces. */} + filled so it never shares the parent's column layout with the layer it replaces. + Held rows read the newer query's hash and snapshot from the Search contexts, so their + actions would target the wrong search — inert until the results they belong to are current. */} {shouldShowLoadingSkeleton ? ( From bcf8824361481f4b0eb31a2c19bf60aa4414675d Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 17 Sep 2026 11:18:21 +0200 Subject: [PATCH 293/485] Keep Spend insights metadata for the header and empty state --- .../SearchRouter/useNavigationSuggestions.tsx | 4 +-- src/hooks/useSearchTypeMenuSections.ts | 26 ++++++++++++++++--- src/libs/SearchUIUtils.ts | 16 +++++++----- src/pages/Search/SearchTypeMenuNarrow.tsx | 4 +-- src/pages/Search/SearchTypeMenuWide.tsx | 4 +-- tests/unit/SearchRouterNavigationTest.ts | 9 ++++--- 6 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 816b824bd686..4e411a354396 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -16,7 +16,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import {useSearchTypeMenuSectionsForDisplay} from '@hooks/useSearchTypeMenuSections'; import useThemeStyles from '@hooks/useThemeStyles'; import navigateToDomainRouteWithSidebarSync from '@libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync'; @@ -396,7 +396,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const {clearSelectedTransactions} = useSearchSelectionActions(); - const typeMenuSections = useSearchTypeMenuSections(shouldWatchForApprovals); + const typeMenuSections = useSearchTypeMenuSectionsForDisplay(shouldWatchForApprovals); const {setCurrentSearchKey} = useSearchQueryActions(); const {accountMenuItemsData, generalMenuItemsData} = useSettingsNavigationMenuData(); diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index c38b9645d2cc..5e17deaee311 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -1,4 +1,5 @@ -import {createTypeMenuSections} from '@libs/SearchUIUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; +import {createTypeMenuSections, SPEND_INSIGHTS_SEARCH_KEYS} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -63,7 +64,6 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { const {defaultCardFeed, cardFeedsByPolicy, activeExpensifyCardFeedID} = useCardFeedsForDisplay(); const {isOffline} = useNetwork(); - const {isBetaEnabled} = usePermissions(); const [allPolicies] = useMappedPolicies(policyMapper); const [currentUserLoginAndAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: currentUserLoginAndAccountIDSelector}); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); @@ -123,7 +123,6 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { isTrackIntentUser: isTrackIntentUser ?? false, hasReportAwaitingApproval, policyCategories: allPolicyCategories, - isInsightsPageBetaEnabled: isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE), }), [ currentUserLoginAndAccountID?.email, @@ -139,11 +138,30 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { isTrackIntentUser, hasReportAwaitingApproval, allPolicyCategories, - isBetaEnabled, ], ); return typeMenuSections; }; +const spendInsightsSearchKeys = new Set(SPEND_INSIGHTS_SEARCH_KEYS); + +const useSearchTypeMenuSectionsForDisplay = (isScreenFocused = true) => { + const typeMenuSections = useSearchTypeMenuSections(isScreenFocused); + const {isBetaEnabled} = usePermissions(); + + if (!isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE)) { + return typeMenuSections; + } + + return typeMenuSections.flatMap((section) => { + const menuItems = section.menuItems.filter((item) => !spendInsightsSearchKeys.has(item.key)); + if (menuItems.length === section.menuItems.length) { + return section; + } + return menuItems.length > 0 ? {...section, menuItems} : []; + }); +}; + export default useSearchTypeMenuSections; +export {useSearchTypeMenuSectionsForDisplay}; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index e962443af51c..6bc52969fdbf 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -4568,6 +4568,13 @@ function isTodoSearch(recentSearchHash: number, suggestedSearches: Record; - isInsightsPageBetaEnabled?: boolean; }; function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuSection[] { @@ -4600,7 +4606,6 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS isTrackIntentUser, hasReportAwaitingApproval = false, policyCategories, - isInsightsPageBetaEnabled = false, } = params; const typeMenuSections: SearchTypeMenuSection[] = []; @@ -4758,11 +4763,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS menuItems: [], }; - const spendInsightsSearchKeys = isInsightsPageBetaEnabled - ? [] - : [CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME, CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES, CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]; - - const insightsSearchKeys = [...spendInsightsSearchKeys, CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]; + const insightsSearchKeys = [...SPEND_INSIGHTS_SEARCH_KEYS, CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]; for (const key of insightsSearchKeys) { if (!suggestedSearchesVisibility[key]) { @@ -7128,6 +7129,7 @@ export { getActions, getPrimaryAction, createTypeMenuSections, + SPEND_INSIGHTS_SEARCH_KEYS, formatBadgeText, getSectionBadgeText, getItemBadgeText, diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index c8c5bf1e0287..35f28628c42e 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -15,7 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportAttributes from '@hooks/useReportAttributes'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import {useSearchTypeMenuSectionsForDisplay} from '@hooks/useSearchTypeMenuSections'; import useShareSavedSearch, {MENU_CLOSE_DELAY_MS} from '@hooks/useShareSavedSearch'; import useThemeStyles from '@hooks/useThemeStyles'; import useTodoCounts from '@hooks/useTodoCounts'; @@ -92,7 +92,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const isFocused = useIsFocused(); - const typeMenuSections = useSearchTypeMenuSections(isFocused); + const typeMenuSections = useSearchTypeMenuSectionsForDisplay(isFocused); const personalDetails = usePersonalDetails(); const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [restoreFocusType, setRestoreFocusType] = useState(); diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index f8d9472d3a9f..fa724fc27238 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -5,7 +5,7 @@ import {useSearchQueryActions, useSearchQueryContext, useSearchSelectionActions} import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import {useSearchTypeMenuSectionsForDisplay} from '@hooks/useSearchTypeMenuSections'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useTodoCounts from '@hooks/useTodoCounts'; @@ -78,7 +78,7 @@ function SearchTypeMenuWide() { const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const typeMenuSections = useSearchTypeMenuSections(); + const typeMenuSections = useSearchTypeMenuSectionsForDisplay(); const {setCurrentSearchKey} = useSearchQueryActions(); const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); // Intentionally left enabled (no focus freeze): the wide menu renders in the search navigator's ExtraContent diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index a97c2e880e2f..1a8bdac1ae79 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -44,7 +44,7 @@ import createMock from '../utils/createMock'; type GetWorkspaceMenuItems = typeof getWorkspaceMenuItems; -const mockUseSearchTypeMenuSections = jest.fn(); +const mockUseSearchTypeMenuSections = jest.fn(); const mockUseMemoizedLazyExpensifyIcons = jest.fn, []>(); const mockUseCreateNavigationSuggestions = jest.fn(() => []); const mockUseSettingsNavigationMenuData = jest.fn<{accountMenuItemsData: MenuSection; generalMenuItemsData: MenuSection}, []>(); @@ -136,7 +136,8 @@ jest.mock('@hooks/useResponsiveLayout', () => ({ jest.mock('@hooks/useSearchTypeMenuSections', () => ({ __esModule: true, - default: (queryParams: unknown, isScreenFocused: boolean) => mockUseSearchTypeMenuSections(queryParams, isScreenFocused), + default: (isScreenFocused?: boolean) => mockUseSearchTypeMenuSections(isScreenFocused), + useSearchTypeMenuSectionsForDisplay: (isScreenFocused?: boolean) => mockUseSearchTypeMenuSections(isScreenFocused), })); jest.mock('@pages/settings/useSettingsNavigationMenuData', () => ({ @@ -984,7 +985,7 @@ describe('Spend Search Router navigation source', () => { initialProps: {shouldWatchForApprovals: false}, }); - expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(false, undefined); + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(false); expect(result.current).toHaveLength(1); expect(result.current.at(0)).toMatchObject({ text: 'Go to Reports', @@ -1001,7 +1002,7 @@ describe('Spend Search Router navigation source', () => { expect(rightElement.props).toMatchObject({text: 'Spend', icon: spendContextIcon, iconSize: variables.fontSizeLabel, showTooltip: false}); rerender({shouldWatchForApprovals: true}); - expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(true, undefined); + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(true); }); it('keeps Create rows reachable when top-level and Spend sources are present', () => { From e6e8ad89454babf7bf50a9313b3dd1b16a328661 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 14:53:06 +0530 Subject: [PATCH 294/485] fix: address review feedback on the export account column Signed-off-by: krishna2323 --- .../WorkspaceCompanyCardsTableRow.tsx | 10 +++--- .../WorkspaceCompanyCardsTable/index.tsx | 30 +++++++++--------- .../WorkspaceExpensifyCardsTableRow.tsx | 4 +-- .../WorkspaceExpensifyCardsTable/index.tsx | 31 ++++++++++--------- .../Tables/WorkspaceMembersTable/index.tsx | 4 +-- .../Tables/getExportAccountColumn.ts | 31 +++++++++++++++++++ .../Tables/memberCellAvatarWidth.ts | 9 ++++++ .../WorkspaceExpensifyCardListPage.tsx | 4 +-- 8 files changed, 82 insertions(+), 41 deletions(-) create mode 100644 src/components/Tables/getExportAccountColumn.ts create mode 100644 src/components/Tables/memberCellAvatarWidth.ts diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx index c151c60c9ee7..0d45779dc15c 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx @@ -131,7 +131,7 @@ function WorkspaceCompanyCardTableRow({ {({hovered}) => ( <> {isAssigned ? ( @@ -147,7 +147,7 @@ function WorkspaceCompanyCardTableRow({ CardFeedIcon )} - + void; }; @@ -207,6 +207,11 @@ function WorkspaceCompanyCardsTable({ key: 'member', label: translate('common.member'), sortable: true, + styling: { + // Cell text never wraps, so without minWidth: 0 the grid track sizes from the full string instead of + // its share and the row overflows the table. + containerStyles: [styles.mnw0], + }, dynamicSizing: { // Whichever of the cardholder's name (or the unassigned label) or their login renders wider decides the column's width. getContentToMeasure: (item) => [ @@ -223,6 +228,9 @@ function WorkspaceCompanyCardsTable({ key: 'card', label: translate('workspace.companyCards.card'), sortable: true, + styling: { + containerStyles: [styles.mnw0], + }, dynamicSizing: { getContentToMeasure: (item) => [{text: formatMaskedCardName(item.cardName), fontSize: fontScale.text}], shouldFitContent: true, @@ -232,22 +240,14 @@ function WorkspaceCompanyCardsTable({ key: 'customCardName', label: translate('workspace.companyCards.cardName'), sortable: true, + styling: { + containerStyles: [styles.mnw0], + }, dynamicSizing: { getContentToMeasure: (item) => (item.customCardName ? [{text: item.customCardName, fontSize: fontScale.text}] : []), }, }, - ...(shouldShowExportAccountColumn - ? [ - { - key: 'exportAccount' as const, - label: translate('workspace.moreFeatures.companyCards.exportAccount'), - sortable: true, - dynamicSizing: { - getContentToMeasure: (item: WorkspaceCompanyCardTableItemData) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), - }, - }, - ] - : []), + ...(shouldShowExportAccountColumn ? [getExportAccountColumn(translate('workspace.moreFeatures.companyCards.exportAccount'), styles)] : []), { key: 'actions', label: '', @@ -286,7 +286,7 @@ function WorkspaceCompanyCardsTable({ assignedCard, cardholder, // Unassigned cards have no details page and so no Accounting section to match, hence no title. - exportAccountTitle: assignedCard ? getCardExportAccountTitle(cardExportSettings, assignedCard) : undefined, + exportAccountTitle: shouldShowExportAccountColumn && assignedCard ? getCardExportAccountTitle(cardExportSettings, assignedCard) : undefined, errors: isFeedConnectionBroken || assignedCard?.pendingFields?.lastScrape ? undefined : assignedCard?.errors, pendingAction: assignedCard?.pendingAction, onDismissError: () => resetFailedWorkspaceCompanyCardUnassignment(domainOrWorkspaceAccountID, bankName, assignedCard?.cardID), diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx index e4686e682cb5..5db51f66b01d 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx @@ -114,7 +114,7 @@ export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldU {({hovered}) => ( <> - + [ @@ -131,6 +136,9 @@ export default function WorkspaceExpensifyCardsTable({ key: 'type', label: translate('common.type'), sortable: true, + styling: { + containerStyles: [styles.mnw0], + }, dynamicSizing: { getContentToMeasure: (item) => [ {text: item.isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'), fontSize: fontScale.text}, @@ -142,6 +150,9 @@ export default function WorkspaceExpensifyCardsTable({ key: 'limitType', label: translate('workspace.card.issueNewCard.limitType'), sortable: true, + styling: { + containerStyles: [styles.mnw0], + }, dynamicSizing: { getContentToMeasure: (item) => [{text: translate(getTranslationKeyForLimitType(item.limitType)), fontSize: fontScale.text}], shouldFitContent: true, @@ -160,6 +171,9 @@ export default function WorkspaceExpensifyCardsTable({ key: 'status', label: translate('common.status'), sortable: true, + styling: { + containerStyles: [styles.mnw0], + }, dynamicSizing: { getContentToMeasure: (item) => { const statusTranslationKey = getTranslationKeyForCardStatus(item.card.state, item.isVirtual); @@ -168,18 +182,7 @@ export default function WorkspaceExpensifyCardsTable({ shouldFitContent: true, }, }, - ...(shouldShowExportAccountColumn - ? [ - { - key: 'exportAccount' as const, - label: translate('workspace.moreFeatures.companyCards.exportAccount'), - sortable: true, - dynamicSizing: { - getContentToMeasure: (item: WorkspaceExpensifyCardTableRowData) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), - }, - }, - ] - : []), + ...(shouldShowExportAccountColumn ? [getExportAccountColumn(translate('workspace.moreFeatures.companyCards.exportAccount'), styles)] : []), { key: 'limit', label: translate('workspace.expensifyCard.limit'), diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 8682ff9cd850..9495b3bdc468 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -1,5 +1,6 @@ import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; +import MEMBER_CELL_AVATAR_WIDTH from '@components/Tables/memberCellAvatarWidth'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -53,9 +54,6 @@ type WorkspaceMembersTableProps = { headerComponent?: React.ReactElement; }; -/** Width the member cell's avatar and the space after it take before the name and email start. */ -const MEMBER_CELL_AVATAR_WIDTH = variables.avatarSizeSmall + 12; - const WORKSPACE_MEMBER_FILTER_VALUES = { ADMINS: 'admins', APPROVERS: 'approvers', diff --git a/src/components/Tables/getExportAccountColumn.ts b/src/components/Tables/getExportAccountColumn.ts new file mode 100644 index 000000000000..29bc4945df99 --- /dev/null +++ b/src/components/Tables/getExportAccountColumn.ts @@ -0,0 +1,31 @@ +import type {TableColumn, TableData} from '@components/Table'; + +import type {ThemeStyles} from '@styles/index'; +import {fontScale} from '@styles/typography'; + +type ExportAccountRowData = TableData & { + /** The account the row's card exports to, as shown in the card details page's Accounting section */ + exportAccountTitle?: string; +}; + +/** + * Builds the Export account column shared by the company cards and Expensify Card tables. + */ +function getExportAccountColumn(label: string, styles: ThemeStyles): TableColumn<'exportAccount', DataType> { + return { + key: 'exportAccount', + label, + sortable: true, + styling: { + // Cell text never wraps, so without minWidth: 0 the grid track sizes from the full account name instead of + // its share and the row overflows the table. + containerStyles: [styles.mnw0], + }, + dynamicSizing: { + getContentToMeasure: (item) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), + }, + }; +} + +export default getExportAccountColumn; +export type {ExportAccountRowData}; diff --git a/src/components/Tables/memberCellAvatarWidth.ts b/src/components/Tables/memberCellAvatarWidth.ts new file mode 100644 index 000000000000..e9e35d5685e5 --- /dev/null +++ b/src/components/Tables/memberCellAvatarWidth.ts @@ -0,0 +1,9 @@ +import variables from '@styles/variables'; + +/** + * Width the member cell's avatar and the gap after it take before the name and subtitle start. Shared by every table + * whose member column measures its content, so the avatar is reserved the same room in all of them. + */ +const MEMBER_CELL_AVATAR_WIDTH = variables.avatarSizeSmall + 12; + +export default MEMBER_CELL_AVATAR_WIDTH; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index c0fc61728dfc..8fa9bc543852 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -159,7 +159,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp currency: settlementCurrency, isVirtual: !!card.nameValuePairs?.isVirtual, limitType: card.nameValuePairs?.limitType, - exportAccountTitle: getCardExportAccountTitle(cardExportSettings, card), + exportAccountTitle: shouldShowExportAccountColumn ? getCardExportAccountTitle(cardExportSettings, card) : undefined, frozenByDisplayName, frozenByAccountID: card.nameValuePairs?.frozen?.byAccountID, frozenDate: card.nameValuePairs?.frozen?.date, @@ -169,7 +169,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp onClose: () => clearDeletePaymentMethodError(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, card.cardID), }; }), - [allCards, cardExportSettings, defaultFundID, personalDetails, settlementCurrency, translate, formatPhoneNumber], + [allCards, cardExportSettings, shouldShowExportAccountColumn, defaultFundID, personalDetails, settlementCurrency, translate, formatPhoneNumber], ); const bulkExportOptions: Array> = [ From 3c69751eeebc50cfa291d32210a82074410e96d5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 15:38:15 +0530 Subject: [PATCH 295/485] refactor: fold the export account column helpers into existing files Signed-off-by: krishna2323 --- .../WorkspaceCompanyCardsTable/index.tsx | 10 +++--- .../WorkspaceExpensifyCardsTable/index.tsx | 5 ++- .../Tables/WorkspaceMembersTable/index.tsx | 3 +- .../Tables/getExportAccountColumn.ts | 31 ------------------- .../Tables/memberCellAvatarWidth.ts | 9 ------ src/pages/workspace/companyCards/utils.tsx | 29 ++++++++++++++++- src/styles/variables.ts | 1 + 7 files changed, 37 insertions(+), 51 deletions(-) delete mode 100644 src/components/Tables/getExportAccountColumn.ts delete mode 100644 src/components/Tables/memberCellAvatarWidth.ts diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index 763a40f5866c..eb1e0121706c 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -5,8 +5,6 @@ import CardFeedIcon from '@components/CardFeedIcon'; import ScrollView from '@components/ScrollView'; import Table, {composeTableListHeader} from '@components/Table'; import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableHandle} from '@components/Table'; -import getExportAccountColumn from '@components/Tables/getExportAccountColumn'; -import MEMBER_CELL_AVATAR_WIDTH from '@components/Tables/memberCellAvatarWidth'; import Text from '@components/Text'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; @@ -25,7 +23,7 @@ import {formatMaskedCardName, getCompanyCardCustomName, getDefaultCardName} from import {getConnectedIntegration} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; -import {getCardExportAccountTitle, getPolicyCardExportSettings} from '@pages/workspace/companyCards/utils'; +import {getCardExportAccountTitle, getExportAccountColumn, getPolicyCardExportSettings} from '@pages/workspace/companyCards/utils'; import WorkspaceCompanyCardPageEmptyState from '@pages/workspace/companyCards/WorkspaceCompanyCardPageEmptyState'; import WorkspaceCompanyCardsFeedPendingPage from '@pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage'; @@ -221,7 +219,7 @@ function WorkspaceCompanyCardsTable({ }, {text: item.isAssigned ? Str.removeSMSDomain(item.cardholder?.login ?? '') : '', fontSize: fontScale.label}, ], - extraWidth: MEMBER_CELL_AVATAR_WIDTH, + extraWidth: variables.tableMemberCellAvatarWidth, }, }, { @@ -232,8 +230,10 @@ function WorkspaceCompanyCardsTable({ containerStyles: [styles.mnw0], }, dynamicSizing: { + // formatMaskedCardName returns the feed's name unchanged unless it is all digits, so this column can + // hold long free text. Fitting it to its content would pin the column's minimum to that width and force + // the whole table to scroll. getContentToMeasure: (item) => [{text: formatMaskedCardName(item.cardName), fontSize: fontScale.text}], - shouldFitContent: true, }, }, { diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx index bf3135eeea70..d1968811a845 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -1,8 +1,6 @@ import FormHelpMessage from '@components/FormHelpMessage'; import Table, {composeTableListHeader} from '@components/Table'; import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table'; -import getExportAccountColumn from '@components/Tables/getExportAccountColumn'; -import MEMBER_CELL_AVATAR_WIDTH from '@components/Tables/memberCellAvatarWidth'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -13,6 +11,7 @@ import {convertToShortDisplayString} from '@libs/CurrencyUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getExportAccountColumn} from '@pages/workspace/companyCards/utils'; import WorkspaceCardListLabels from '@pages/workspace/expensifyCard/WorkspaceCardListLabels'; import {fontScale} from '@styles/typography'; @@ -129,7 +128,7 @@ export default function WorkspaceExpensifyCardsTable({ {text: temporaryGetDisplayNameOrDefault({passedPersonalDetails: item.cardholder, translate, formatPhoneNumber}), fontSize: fontScale.text}, {text: item.name, fontSize: fontScale.label}, ], - extraWidth: MEMBER_CELL_AVATAR_WIDTH, + extraWidth: variables.tableMemberCellAvatarWidth, }, }, { diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 9495b3bdc468..f9d2769466b1 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -1,6 +1,5 @@ import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; -import MEMBER_CELL_AVATAR_WIDTH from '@components/Tables/memberCellAvatarWidth'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -92,7 +91,7 @@ export default function WorkspaceMembersTable({ {text: item.name, fontSize: fontScale.text}, {text: item.email, fontSize: fontScale.label}, ], - extraWidth: MEMBER_CELL_AVATAR_WIDTH, + extraWidth: variables.tableMemberCellAvatarWidth, }, }, diff --git a/src/components/Tables/getExportAccountColumn.ts b/src/components/Tables/getExportAccountColumn.ts deleted file mode 100644 index 29bc4945df99..000000000000 --- a/src/components/Tables/getExportAccountColumn.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type {TableColumn, TableData} from '@components/Table'; - -import type {ThemeStyles} from '@styles/index'; -import {fontScale} from '@styles/typography'; - -type ExportAccountRowData = TableData & { - /** The account the row's card exports to, as shown in the card details page's Accounting section */ - exportAccountTitle?: string; -}; - -/** - * Builds the Export account column shared by the company cards and Expensify Card tables. - */ -function getExportAccountColumn(label: string, styles: ThemeStyles): TableColumn<'exportAccount', DataType> { - return { - key: 'exportAccount', - label, - sortable: true, - styling: { - // Cell text never wraps, so without minWidth: 0 the grid track sizes from the full account name instead of - // its share and the row overflows the table. - containerStyles: [styles.mnw0], - }, - dynamicSizing: { - getContentToMeasure: (item) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), - }, - }; -} - -export default getExportAccountColumn; -export type {ExportAccountRowData}; diff --git a/src/components/Tables/memberCellAvatarWidth.ts b/src/components/Tables/memberCellAvatarWidth.ts deleted file mode 100644 index e9e35d5685e5..000000000000 --- a/src/components/Tables/memberCellAvatarWidth.ts +++ /dev/null @@ -1,9 +0,0 @@ -import variables from '@styles/variables'; - -/** - * Width the member cell's avatar and the gap after it take before the name and subtitle start. Shared by every table - * whose member column measures its content, so the avatar is reserved the same room in all of them. - */ -const MEMBER_CELL_AVATAR_WIDTH = variables.avatarSizeSmall + 12; - -export default MEMBER_CELL_AVATAR_WIDTH; diff --git a/src/pages/workspace/companyCards/utils.tsx b/src/pages/workspace/companyCards/utils.tsx index 22024e2325f8..e384fb3eb9a6 100644 --- a/src/pages/workspace/companyCards/utils.tsx +++ b/src/pages/workspace/companyCards/utils.tsx @@ -1,5 +1,6 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {SelectorType} from '@components/SelectionScreen'; +import type {TableColumn, TableData} from '@components/Table'; import {sortDefaultToTop} from '@libs/ListUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -10,6 +11,7 @@ import {getSageIntacctNonReimbursableActiveDefaultVendor} from '@libs/PolicyUtil import {getCurrentAccountingIntegrationName} from '@pages/workspace/accounting/utils'; import type {ThemeStyles} from '@styles/index'; +import {fontScale} from '@styles/typography'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -32,6 +34,11 @@ type ExportIntegration = { shouldShowMenuItem?: boolean; }; +type ExportAccountRowData = TableData & { + /** The account the row's card exports to, as shown in the card details page's Accounting section */ + exportAccountTitle?: string; +}; + /** An export account candidate, normalized so every integration shares one resolution path. */ type ExportAccountOption = { /** Value stored in the card's export NVP, and the option's value */ @@ -552,6 +559,25 @@ function getCardExportAccountTitle(settings: CardExportSettings | undefined, com return getCardExportAccountSelection(settings, companyCard).title; } +/** + * Builds the Export account column shared by the company cards and Expensify Card tables. + */ +function getExportAccountColumn(label: string, styles: ThemeStyles): TableColumn<'exportAccount', DataType> { + return { + key: 'exportAccount', + label, + sortable: true, + styling: { + // Cell text never wraps, so without minWidth: 0 the grid track sizes from the full account name instead of + // its share and the row overflows the table. + containerStyles: [styles.mnw0], + }, + dynamicSizing: { + getContentToMeasure: (item) => (item.exportAccountTitle ? [{text: item.exportAccountTitle, fontSize: fontScale.text}] : []), + }, + }; +} + function getExportMenuItem( connectionName: PolicyConnectionName | undefined, policyID: string, @@ -617,4 +643,5 @@ function getCompanyCardDetailsBackPath( return createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute(feed, cardID), detailsBasePath); } -export {getCardExportAccountTitle, getCompanyCardDetailsBackPath, getExportMenuItem, getPolicyCardExportSettings}; +export {getCardExportAccountTitle, getCompanyCardDetailsBackPath, getExportAccountColumn, getExportMenuItem, getPolicyCardExportSettings}; +export type {ExportAccountRowData}; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index b237c6ae4bff..e4fa6628b181 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -152,6 +152,7 @@ export default { tableRequireFieldsTypeColumnWidth: 112, tableSwitchColumnWidth: 58, tableCaretColumnWidth: 20, + tableMemberCellAvatarWidth: avatarSizes.avatarSizeSmall + 12, workspaceTagsTableCountColumnWidth: 100, domainTableActionColumnWidth: 64, domainAdminsTableActionColumnWidth: 220, From 1b81ea26b2cbcdd6dc13d7d7c8c7cfe9eb19ff94 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 15:44:06 +0530 Subject: [PATCH 296/485] fix: stop exporting ExportAccountRowData Signed-off-by: krishna2323 --- src/pages/workspace/companyCards/utils.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/workspace/companyCards/utils.tsx b/src/pages/workspace/companyCards/utils.tsx index e384fb3eb9a6..4ffcebb7a71e 100644 --- a/src/pages/workspace/companyCards/utils.tsx +++ b/src/pages/workspace/companyCards/utils.tsx @@ -644,4 +644,3 @@ function getCompanyCardDetailsBackPath( } export {getCardExportAccountTitle, getCompanyCardDetailsBackPath, getExportAccountColumn, getExportMenuItem, getPolicyCardExportSettings}; -export type {ExportAccountRowData}; From 092ee9951acb915c2f767a20cc961a898a84abbe Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 15:48:59 +0530 Subject: [PATCH 297/485] fix: cap the card column instead of fitting it to unbounded content Signed-off-by: krishna2323 --- .../Tables/WorkspaceCompanyCardsTable/index.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index eb1e0121706c..cd4650ded2e1 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -230,10 +230,14 @@ function WorkspaceCompanyCardsTable({ containerStyles: [styles.mnw0], }, dynamicSizing: { - // formatMaskedCardName returns the feed's name unchanged unless it is all digits, so this column can - // hold long free text. Fitting it to its content would pin the column's minimum to that width and force - // the whole table to scroll. getContentToMeasure: (item) => [{text: formatMaskedCardName(item.cardName), fontSize: fontScale.text}], + // A masked card number ellipsizes at its tail, which is the part identifying the card, so it is never + // squeezed below its content. + shouldFitContent: true, + // formatMaskedCardName returns the feed's name unchanged unless it is all digits, so this column can + // also hold long free text. The cap bounds what such a name may reserve, since the width a fitted column + // asks for is also the width it refuses to go below. + maxWidth: CONST.TABLES.DYNAMIC_COLUMNS.MAX_FREE_TEXT_COLUMN_WIDTH, }, }, { From dbd9eebed2e28eb13cdcff519fda360f50812c4d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 15:52:57 +0530 Subject: [PATCH 298/485] remove comments. Signed-off-by: krishna2323 --- src/components/Tables/WorkspaceCompanyCardsTable/index.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index cd4650ded2e1..7ef1eb65e371 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -231,12 +231,7 @@ function WorkspaceCompanyCardsTable({ }, dynamicSizing: { getContentToMeasure: (item) => [{text: formatMaskedCardName(item.cardName), fontSize: fontScale.text}], - // A masked card number ellipsizes at its tail, which is the part identifying the card, so it is never - // squeezed below its content. shouldFitContent: true, - // formatMaskedCardName returns the feed's name unchanged unless it is all digits, so this column can - // also hold long free text. The cap bounds what such a name may reserve, since the width a fitted column - // asks for is also the width it refuses to go below. maxWidth: CONST.TABLES.DYNAMIC_COLUMNS.MAX_FREE_TEXT_COLUMN_WIDTH, }, }, From e296c87154a106bc332c352967fe111fc817447b Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 16:29:31 +0530 Subject: [PATCH 299/485] fix: address review findings on the Add existing expense table Signed-off-by: krishna2323 --- .../SelectionToolbar/index.tsx | 19 ++++------- .../Skeletons/UnreportedExpensesSkeleton.tsx | 7 ++-- src/components/Table/Table.tsx | 34 +++++++++++++++++-- src/components/Table/TableContext.tsx | 9 ++++- .../TableDisplaySettingsPopoverComponent.tsx | 5 +-- src/components/Table/TableRow.tsx | 6 ++-- src/components/Table/middlewares/selection.ts | 32 ++++++++++++----- src/components/Table/types.ts | 16 +++++++++ .../AddExistingExpenseTableRow.tsx | 8 +++-- .../Tables/AddExistingExpenseTable/index.tsx | 21 ++++++++++-- src/pages/AddExistingExpense.tsx | 10 +++--- 11 files changed, 126 insertions(+), 41 deletions(-) diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index c30dcf322ddf..e0d0db95a5d1 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -45,7 +45,7 @@ import {View} from 'react-native'; import SelectAllCheckbox from './SelectAllCheckbox'; import SelectionDropdown from './SelectionDropdown'; -type SelectionToolbarGateProps = { +type SelectionToolbarProps = { reportID: string; /** Filtered transactions for this report */ @@ -55,12 +55,7 @@ type SelectionToolbarGateProps = { reportActions: OnyxTypes.ReportAction[]; }; -type SelectionToolbarProps = SelectionToolbarGateProps & { - /** Whether mobile selection mode is enabled, read once by the gate that mounts this component */ - isMobileSelectionModeActive: boolean; -}; - -function SelectionToolbar({reportID, transactions, reportActions, isMobileSelectionModeActive}: SelectionToolbarProps) { +function SelectionToolbar({reportID, transactions, reportActions}: SelectionToolbarProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetworkWithOfflineStatus(); @@ -83,6 +78,7 @@ function SelectionToolbar({reportID, transactions, reportActions, isMobileSelect useFilterSelectedTransactions(transactions, reportID); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); const {showConfirmModal} = useConfirmModal(); const [offlineModalVisible, setOfflineModalVisible] = useState(false); @@ -251,7 +247,7 @@ function SelectionToolbar({reportID, transactions, reportActions, isMobileSelect onAfterDuplicate={() => clearSelectedTransactions(true)} /> )} - {shouldUseNarrowLayout && isMobileSelectionModeActive && ( + {shouldUseNarrowLayout && isMobileSelectionModeEnabled && ( { if (reportID && currentSelectedTransactionReportID !== reportID && selectedTransactionIDs.length > 0) { @@ -338,7 +334,7 @@ function SelectionToolbarGate({reportID, transactions, reportActions}: Selection setCurrentSelectedTransactionReportID(reportID); }); - if (selectedTransactionIDs.length === 0 && !isMobileSelectionModeActive) { + if (selectedTransactionIDs.length === 0 && !isMobileSelectionModeEnabled) { return null; } @@ -347,7 +343,6 @@ function SelectionToolbarGate({reportID, transactions, reportActions}: Selection reportID={reportID} transactions={transactions} reportActions={reportActions} - isMobileSelectionModeActive={isMobileSelectionModeActive} /> ); } diff --git a/src/components/Skeletons/UnreportedExpensesSkeleton.tsx b/src/components/Skeletons/UnreportedExpensesSkeleton.tsx index 0b7258a87294..d1e293ac6efa 100644 --- a/src/components/Skeletons/UnreportedExpensesSkeleton.tsx +++ b/src/components/Skeletons/UnreportedExpensesSkeleton.tsx @@ -41,9 +41,8 @@ type UnreportedExpensesSkeletonProps = { function UnreportedExpensesSkeleton({fixedNumberOfItems}: UnreportedExpensesSkeletonProps) { const {onLayout, containerWidth: pageWidth} = useContainerWidth(40); const styles = useThemeStyles(); - // Passed only when this is the pagination-loading footer appended below already-rendered table rows, not the - // full-page loading state, so it picks up a top divider connecting it to the real row above it instead of - // rounding its own top corners. + // Passed only when this is the pagination-loading footer appended below already-rendered table rows, so it picks up + // a top divider connecting it to the real row above it instead of rounding its own top corners. const isPaginationFooter = !!fixedNumberOfItems; const skeletonItem = useCallback( @@ -95,7 +94,7 @@ function UnreportedExpensesSkeleton({fixedNumberOfItems}: UnreportedExpensesSkel itemViewHeight={72} itemViewStyle={[styles.highlightBG, styles.mr0]} itemContainerStyle={styles.borderBottom} - style={[styles.mh5, styles.overflowHidden, styles.tableBottomRadius, isPaginationFooter && styles.borderTop]} + style={[styles.mh5, styles.overflowHidden, styles.tableBottomRadius, isPaginationFooter ? styles.borderTop : styles.tableTopRadius]} shouldAnimate fixedNumItems={fixedNumberOfItems} renderSkeletonItem={skeletonItem} diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index b0e06078c2a4..67544081936e 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -9,7 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import getPlatform from '@libs/getPlatform'; import {canMeasureText} from '@libs/measureTextWidth'; import {acquireBackgroundInputFocusSuppression} from '@libs/ModalFocusManager'; @@ -274,12 +274,34 @@ function Table) { const {translate} = useLocalize(); - const isMobileSelectionEnabled = useMobileSelectionMode(); + const isGlobalMobileSelectionEnabled = useMobileSelectionMode(); + // A table whose owner passes the mode in keeps it to itself. The app wide mode is shared with every other screen, + // so an unrelated one can turn it off while this table is still using it. + const isMobileSelectionModeControlled = !!onMobileSelectionModeChange; + const isMobileSelectionEnabled = isMobileSelectionModeControlled ? !!isMobileSelectionModeEnabled : isGlobalMobileSelectionEnabled; + + const setMobileSelectionModeEnabled = (isEnabled: boolean) => { + if (isMobileSelectionModeControlled) { + onMobileSelectionModeChange(isEnabled); + return; + } + + if (isEnabled) { + turnOnMobileSelectionMode(); + return; + } + + turnOffMobileSelectionMode(); + }; const icons = useMemoizedLazyExpensifyIcons(['CheckSquare']); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: true, addOfflineIndicatorBottomSafeAreaPadding: false}); @@ -322,6 +344,9 @@ function Table void; }; @@ -129,6 +135,7 @@ const defaultTableContextValue: TableContextValue = { order: 'asc', }, initialSortColumn: undefined, + initialSortOrder: 'asc', narrowLayoutSortColumn: undefined, activeSearchString: '', tableMethods: {} as TableMethods, diff --git a/src/components/Table/TableFilterBar/TableDisplaySettingsPopoverComponent.tsx b/src/components/Table/TableFilterBar/TableDisplaySettingsPopoverComponent.tsx index 015297a4f182..94cf4372d32c 100644 --- a/src/components/Table/TableFilterBar/TableDisplaySettingsPopoverComponent.tsx +++ b/src/components/Table/TableFilterBar/TableDisplaySettingsPopoverComponent.tsx @@ -93,13 +93,14 @@ export default function TableDisplaySettingsPopoverComponent({closeOverlay}: Tab columns, activeSorting, initialSortColumn, + initialSortOrder, tableMethods: {updateSorting}, } = useTableContext(); const [selectedSetting, setSelectedSetting] = useState<'sortBy' | 'sortOrder' | null>(null); const defaultSorting: ActiveSorting = { columnKey: initialSortColumn, - order: CONST.SEARCH.SORT_ORDER.ASC, + order: initialSortOrder, }; const [pendingSorting, setPendingSorting] = useState>(defaultSorting); @@ -168,7 +169,7 @@ export default function TableDisplaySettingsPopoverComponent({closeOverlay}: Tab ]} value={selectedOrder} label={translate('search.display.sortOrder')} - defaultValue={CONST.SEARCH.SORT_ORDER.ASC} + defaultValue={defaultSorting.order} onBackButtonPress={() => setSelectedSetting('sortBy')} closeOverlay={closeSettingsOverlay} onChange={(item) => { diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 31fa60e7476e..768002e2bfc9 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -89,6 +89,7 @@ export default function TableRow({ tableListMetadata, dynamicGridTemplateColumns, listProps, + shouldFooterRenderAsLastRow, } = useTableContext(); const semanticRowID = useTableRowSemanticID(); const semanticTableHasHeader = !tableListMetadata.hasPageHeader || tableListMetadata.shouldRenderStickyHeader; @@ -111,8 +112,9 @@ export default function TableRow({ const isDisabled = !!disabled || isAccessibilityHidden; const isFirstRow = rowIndex === 0; - // A footer renders below the rows, so it owns the rounded bottom corners instead of the last row. - const isLastRow = rowIndex === rowCount - 1 && !listProps?.ListFooterComponent; + // A footer that continues the rows owns the rounded bottom corners instead of the last row. + const doesFooterOwnBottomRadius = !!shouldFooterRenderAsLastRow && !!listProps?.ListFooterComponent; + const isLastRow = rowIndex === rowCount - 1 && !doesFooterOwnBottomRadius; if (selectionEnabled && isSelectionCheckboxVisible) { gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index c47b22ca1ad4..67c0efd7e61b 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -1,12 +1,10 @@ import type {TableData, TableRow} from '@components/Table/types'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; -import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; -import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {Dispatch, SetStateAction} from 'react'; @@ -34,6 +32,15 @@ type UseSelectionProps = { /** Whether the selection mode should key off the real screen size instead of shouldUseNarrowLayout (for tables inside a narrow pane modal / RHP) */ shouldEnableSelectionInNarrowPaneModal?: boolean; + + /** Whether the mobile selection mode is currently on */ + isSelectionModeEnabled: boolean; + + /** Turns the mobile selection mode on or off */ + setSelectionModeEnabled: (isEnabled: boolean) => void; + + /** Whether the selection survives a change to the search string or the filters */ + shouldPreserveSelectionOnSearchAndFilter?: boolean; }; type SelectionMethods = { @@ -66,6 +73,9 @@ export default function useSelection({ activeSearchString, onRowSelectionChange, shouldEnableSelectionInNarrowPaneModal, + isSelectionModeEnabled, + setSelectionModeEnabled, + shouldPreserveSelectionOnSearchAndFilter, }: UseSelectionProps): UseSelectionResult { // When a table opts into selection inside a narrow pane modal (RHP), the selection-mode auto-sync keys off the real // screen size (isSmallScreenWidth) so it behaves correctly there (shouldUseNarrowLayout is always true in an RHP). @@ -73,7 +83,6 @@ export default function useSelection({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const selectionUsesNarrowLayout = shouldEnableSelectionInNarrowPaneModal ? isSmallScreenWidth : shouldUseNarrowLayout; - const isSelectionModeEnabled = useMobileSelectionMode(); // When a user long-presses a row on mobile, store the key of the row that will be selected if // the user confirms the selection @@ -103,9 +112,9 @@ export default function useSelection({ } clearSelection(); - turnOffMobileSelectionMode(); + setSelectionModeEnabled(false); return true; - }, [isSelectionModeEnabled, clearSelection]); + }, [isSelectionModeEnabled, clearSelection, setSelectionModeEnabled]); useAndroidBackButtonHandler(androidBackButtonDisableSelectionMode); @@ -116,10 +125,11 @@ export default function useSelection({ const isSelectionModeEnabledWithoutSelectableKeys = isSelectionModeEnabled && !selectableKeys.length && !originalSelectableCount; if (isMobileMissingSelectionMode) { - turnOnMobileSelectionMode(); + setSelectionModeEnabled(true); } else if (isDesktopWithoutSelectableKeys || isSelectionModeEnabledWithoutSelectableKeys) { - turnOffMobileSelectionMode(); + setSelectionModeEnabled(false); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectionUsesNarrowLayout, isSelectionModeEnabled, selectedKeys.length, originalSelectableCount, selectableKeys.length]); // When selection mode is turned off, clear the list of selected keys, so that re-enabling selection mode doesn't retain rows @@ -133,7 +143,13 @@ export default function useSelection({ }, [isSelectionModeEnabled, selectedKeys.length, clearSelection, wasSelectionModeEnabled]); // When the table filters or the search string change, clear the current selection - useEffect(() => clearSelection(), [currentFilters, activeSearchString, clearSelection]); + useEffect(() => { + if (shouldPreserveSelectionOnSearchAndFilter) { + return; + } + + clearSelection(); + }, [currentFilters, activeSearchString, clearSelection, shouldPreserveSelectionOnSearchAndFilter]); // When the table unmounts, clear the selection. Should only run on unmount // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 8172be125b45..b5f7b4e58f1d 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -200,6 +200,22 @@ type TableProps void; + + /** Whether the selection survives a change to the search string or the filters, e.g. when rows are confirmed in one batch. */ + shouldPreserveSelectionOnSearchAndFilter?: boolean; + + /** Whether `ListFooterComponent` renders as a continuation of the rows, so it owns the rounded bottom corners instead of the last row. */ + shouldFooterRenderAsLastRow?: boolean; + /** Column configuration defining what columns to display and how. */ columns: Array>; diff --git a/src/components/Tables/AddExistingExpenseTable/AddExistingExpenseTableRow.tsx b/src/components/Tables/AddExistingExpenseTable/AddExistingExpenseTableRow.tsx index a3f6a5bb0760..958316b2fe6e 100644 --- a/src/components/Tables/AddExistingExpenseTable/AddExistingExpenseTableRow.tsx +++ b/src/components/Tables/AddExistingExpenseTable/AddExistingExpenseTableRow.tsx @@ -4,6 +4,7 @@ import TransactionItemRow from '@components/TransactionItemRow'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -26,6 +27,8 @@ function AddExistingExpenseTableRow({item, rowIndex, shouldUseNarrowTableLayout} const styles = useThemeStyles(); // The item FlashList passes to renderItem is the plain row data, so selection state comes from processedData. const {tableMethods, processedData} = useTableContext(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); const rowData = processedData.at(rowIndex); const isSelected = !!rowData?.selected; const isRowDisabled = !!rowData?.disabled; @@ -45,9 +48,10 @@ function AddExistingExpenseTableRow({item, rowIndex, shouldUseNarrowTableLayout} accessibilityLabel={accessibilityLabel} sentryLabel={CONST.SENTRY_LABEL.SEARCH.UNREPORTED_EXPENSE_LIST_ITEM} offlineWithFeedback={{pendingAction: item.pendingAction}} - // This list has no per-row navigation, so the whole row is the selection target. + // This list has no per-row navigation, so the whole row is the selection target. On small screens the row is + // only a target once selection mode is on, which the user enters by long pressing, as in the other tables. onPress={() => { - if (item.isSelectionDisabled) { + if (item.isSelectionDisabled || isSmallScreenWidth) { return; } tableMethods.handleSingleRowSelection(item.keyForList); diff --git a/src/components/Tables/AddExistingExpenseTable/index.tsx b/src/components/Tables/AddExistingExpenseTable/index.tsx index 2b4dd0161501..10713a9917bd 100644 --- a/src/components/Tables/AddExistingExpenseTable/index.tsx +++ b/src/components/Tables/AddExistingExpenseTable/index.tsx @@ -25,9 +25,20 @@ type AddExistingExpenseTableProps = Pick< data: UnreportedExpenseTableRowData[]; selectedKeys: string[]; onRowSelectionChange: (selectedRowKeys: string[]) => void; + isMobileSelectionModeEnabled: boolean; + onMobileSelectionModeChange: (isEnabled: boolean) => void; }; -function AddExistingExpenseTable({data, selectedKeys, onRowSelectionChange, onEndReached, onEndReachedThreshold, ListFooterComponent}: AddExistingExpenseTableProps) { +function AddExistingExpenseTable({ + data, + selectedKeys, + onRowSelectionChange, + isMobileSelectionModeEnabled, + onMobileSelectionModeChange, + onEndReached, + onEndReachedThreshold, + ListFooterComponent, +}: AddExistingExpenseTableProps) { const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); @@ -119,10 +130,14 @@ function AddExistingExpenseTable({data, selectedKeys, onRowSelectionChange, onEn columns={columns} selectionEnabled shouldEnableSelectionInNarrowPaneModal - // Expenses are confirmed in one batch, so searching must not drop what is already picked. - shouldPreserveSelectionOnSearch + // Expenses are confirmed in one batch, so searching or filtering must not drop what is already picked. + shouldPreserveSelectionOnSearchAndFilter + // The skeleton appended while the next page loads continues the rows, so it draws the bottom corners. + shouldFooterRenderAsLastRow selectedKeys={selectedKeys} onRowSelectionChange={onRowSelectionChange} + isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} + onMobileSelectionModeChange={onMobileSelectionModeChange} initialSortColumn="date" initialSortOrder="desc" compareItems={compareItems} diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index 2883eb1e9704..57f36a6c379e 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -12,14 +12,12 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; @@ -76,7 +74,9 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { // The table enables selection in this narrow pane modal off the real screen size, so the header has to match it. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const isMobileSelectionModeEnabled = useMobileSelectionMode(); + // This page keeps the selection mode to itself rather than using the app wide one, which the report behind this + // modal also reads and would turn off while the user is still picking expenses. + const [isMobileSelectionModeEnabled, setIsMobileSelectionModeEnabled] = useState(false); const shouldShowSelectionModeHeader = isMobileSelectionModeEnabled && isSmallScreenWidth; const transactionsSelector = useCallback( @@ -212,7 +212,7 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { onBackButtonPress={() => { if (shouldShowSelectionModeHeader) { setSelectedIds(new Set()); - turnOffMobileSelectionMode(); + setIsMobileSelectionModeEnabled(false); return; } Navigation.goBack(); @@ -223,6 +223,8 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { data={unreportedExpenses} selectedKeys={[...selectedIds]} onRowSelectionChange={onRowSelectionChange} + isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} + onMobileSelectionModeChange={setIsMobileSelectionModeEnabled} onEndReached={fetchMoreUnreportedTransactions} onEndReachedThreshold={0.75} ListFooterComponent={paginationFooterContent} From 465232e2cd415d29d13e09698b0b510868381f58 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 16:34:08 +0530 Subject: [PATCH 300/485] fix: justify the new lint suppressions and simplify the new comments Signed-off-by: krishna2323 --- src/components/Skeletons/UnreportedExpensesSkeleton.tsx | 4 ++-- src/components/Table/Table.tsx | 7 +++++-- src/components/Table/middlewares/selection.ts | 2 ++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/Skeletons/UnreportedExpensesSkeleton.tsx b/src/components/Skeletons/UnreportedExpensesSkeleton.tsx index d1e293ac6efa..acc84dde3944 100644 --- a/src/components/Skeletons/UnreportedExpensesSkeleton.tsx +++ b/src/components/Skeletons/UnreportedExpensesSkeleton.tsx @@ -41,8 +41,8 @@ type UnreportedExpensesSkeletonProps = { function UnreportedExpensesSkeleton({fixedNumberOfItems}: UnreportedExpensesSkeletonProps) { const {onLayout, containerWidth: pageWidth} = useContainerWidth(40); const styles = useThemeStyles(); - // Passed only when this is the pagination-loading footer appended below already-rendered table rows, so it picks up - // a top divider connecting it to the real row above it instead of rounding its own top corners. + // Passed only when the skeleton is the footer appended below rows that are already on screen, so it gets a top + // divider joining it to the row above instead of rounding its own top corners. const isPaginationFooter = !!fixedNumberOfItems; const skeletonItem = useCallback( diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 67544081936e..4ee80c915741 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -284,8 +284,8 @@ function Table) { const {translate} = useLocalize(); const isGlobalMobileSelectionEnabled = useMobileSelectionMode(); - // A table whose owner passes the mode in keeps it to itself. The app wide mode is shared with every other screen, - // so an unrelated one can turn it off while this table is still using it. + // The app wide selection mode is shared with every other screen, so an unrelated screen can turn it off while this + // table is still using it. A table that passes its own mode in keeps it to itself instead. const isMobileSelectionModeControlled = !!onMobileSelectionModeChange; const isMobileSelectionEnabled = isMobileSelectionModeControlled ? !!isMobileSelectionModeEnabled : isGlobalMobileSelectionEnabled; @@ -469,6 +469,9 @@ function Table({ } else if (isDesktopWithoutSelectableKeys || isSelectionModeEnabledWithoutSelectableKeys) { setSelectionModeEnabled(false); } + // setSelectionModeEnabled is omitted from the dependencies below because a caller is free to pass an inline + // callback, and re-running this effect on every render would fight the selection mode it just set. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectionUsesNarrowLayout, isSelectionModeEnabled, selectedKeys.length, originalSelectableCount, selectableKeys.length]); From ae021417335c83834b6a350fce98390b848c6a39 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Thu, 17 Sep 2026 14:21:37 +0200 Subject: [PATCH 301/485] Fix padding --- .../SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx index 3bc0c18ca9b3..d8e6b7b3ff94 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx @@ -39,10 +39,10 @@ function SearchAdvancedFiltersBase() { policyID={getFilterNegatableValue(CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, currentDraftFilters)} onPress={(filterKey) => Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.getRoute(filterKey))} /> - + {shouldShowResetFilters && ( )}