From 04bb32f668e717845c0eb72525b020d9f0f4a411 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 28 Jan 2025 16:40:45 +0700 Subject: [PATCH 001/527] fix: the mentioned room is not highlighted in policy profile page --- .../MentionReportContext.tsx | 1 + .../MentionReportRenderer/index.tsx | 13 ++++++---- src/libs/actions/Policy/Policy.ts | 2 +- src/pages/workspace/WorkspaceProfilePage.tsx | 25 +++++++++++-------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx index ddaab1a55994b..85b1b0d8f723e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx @@ -3,6 +3,7 @@ import {createContext} from 'react'; type MentionReportContextProps = { currentReportID: string | undefined; exactlyMatch?: boolean; + policyID?: string; }; const MentionReportContext = createContext({ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index fcae31dd7d2f0..bb049bdfe214a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -26,7 +26,7 @@ type MentionReportRendererProps = CustomRendererProps; const removeLeadingLTRAndHash = (value: string) => value.replace(CONST.UNICODE.LTR, '').replace('#', ''); -const getMentionDetails = (htmlAttributeReportID: string, currentReport: OnyxEntry, reports: OnyxCollection, tnode: TText | TPhrasing) => { +const getMentionDetails = (htmlAttributeReportID: string, currentReport: OnyxEntry, reports: OnyxCollection, tnode: TText | TPhrasing, policyID?: string) => { let reportID: string | undefined; let mentionDisplayText: string; @@ -41,7 +41,7 @@ const getMentionDetails = (htmlAttributeReportID: string, currentReport: OnyxEnt // eslint-disable-next-line rulesdir/prefer-early-return Object.values(reports ?? {}).forEach((report) => { - if (report?.policyID === currentReport?.policyID && removeLeadingLTRAndHash(report?.reportName ?? '') === mentionDisplayText) { + if (report?.policyID === (currentReport?.policyID ?? policyID) && removeLeadingLTRAndHash(report?.reportName ?? '') === mentionDisplayText) { reportID = report?.reportID; } }); @@ -56,7 +56,7 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const htmlAttributeReportID = tnode.attributes.reportid; - const {currentReportID: currentReportIDContext, exactlyMatch} = useContext(MentionReportContext); + const {currentReportID: currentReportIDContext, exactlyMatch, policyID} = useContext(MentionReportContext); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const currentReportID = useCurrentReportID(); @@ -65,9 +65,12 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportIDValue}`); // When we invite someone to a room they don't have the policy object, but we still want them to be able to see and click on report mentions, so we only check if the policyID in the report is from a workspace - const isGroupPolicyReport = useMemo(() => currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE, [currentReport]); + const isGroupPolicyReport = useMemo( + () => policyID ?? (currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE), + [currentReport, policyID], + ); - const mentionDetails = getMentionDetails(htmlAttributeReportID, currentReport, reports, tnode); + const mentionDetails = getMentionDetails(htmlAttributeReportID, currentReport, reports, tnode, policyID); if (!mentionDetails) { return null; } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 1d1090996e489..347ee9441936d 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1401,7 +1401,7 @@ function updateWorkspaceDescription(policyID: string, description: string, curre if (description === currentDescription) { return; } - const parsedDescription = ReportUtils.getParsedComment(description); + const parsedDescription = ReportUtils.getParsedComment(description, {policyID}); const optimisticData: OnyxUpdate[] = [ { diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx index b8e904c2b78b3..39f715b0e7640 100644 --- a/src/pages/workspace/WorkspaceProfilePage.tsx +++ b/src/pages/workspace/WorkspaceProfilePage.tsx @@ -1,5 +1,5 @@ import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import type {ImageStyle, StyleProp} from 'react-native'; import {Image, StyleSheet, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -7,6 +7,7 @@ import Avatar from '@components/Avatar'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; +import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -181,6 +182,8 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: Workspac } }, [policy?.id, policyName, activeWorkspaceID, setActiveWorkspaceID]); + const mentionReportContextValue = useMemo(() => ({policyID: policy?.id, currentReportID: undefined}), [policy]); + return ( - + + + )} Date: Tue, 16 Sep 2025 18:20:19 +0800 Subject: [PATCH 002/527] fix viewport is scrolled in some pages on iOS mWeb --- src/pages/workspace/WorkspacesListPage.tsx | 1 + src/pages/workspace/categories/WorkspaceCategoriesPage.tsx | 1 + src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx | 1 + src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx | 1 + src/pages/workspace/tags/WorkspaceTagsPage.tsx | 1 + src/pages/workspace/taxes/WorkspaceTaxesPage.tsx | 1 + 6 files changed, 6 insertions(+) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index fb30eae0ac763..dd796cf731592 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -560,6 +560,7 @@ function WorkspacesListPage() { return ( Date: Wed, 3 Dec 2025 00:46:36 +0700 Subject: [PATCH 003/527] fix: conflicts --- src/pages/workspace/WorkspaceProfilePage.tsx | 398 ------------------- 1 file changed, 398 deletions(-) delete mode 100644 src/pages/workspace/WorkspaceProfilePage.tsx diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx deleted file mode 100644 index 39f715b0e7640..0000000000000 --- a/src/pages/workspace/WorkspaceProfilePage.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; -import type {ImageStyle, StyleProp} from 'react-native'; -import {Image, StyleSheet, View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import Avatar from '@components/Avatar'; -import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; -import Button from '@components/Button'; -import ConfirmModal from '@components/ConfirmModal'; -import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; -import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import Section from '@components/Section'; -import useActiveWorkspace from '@hooks/useActiveWorkspace'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePermissions from '@hooks/usePermissions'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeIllustrations from '@hooks/useThemeIllustrations'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {FullScreenNavigatorParamList, RootStackParamList, State} from '@libs/Navigation/types'; -import Parser from '@libs/Parser'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import StringUtils from '@libs/StringUtils'; -import * as UserUtils from '@libs/UserUtils'; -import * as Member from '@userActions/Policy/Member'; -import * as Policy from '@userActions/Policy/Policy'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {WithPolicyProps} from './withPolicy'; -import withPolicy from './withPolicy'; -import WorkspacePageWithSections from './WorkspacePageWithSections'; - -type WorkspaceProfilePageProps = WithPolicyProps & PlatformStackScreenProps; - -function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: WorkspaceProfilePageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const illustrations = useThemeIllustrations(); - const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); - const {canUseSpotnanaTravel} = usePermissions(); - - const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); - const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID}); - - // When we create a new workspace, the policy prop will be empty on the first render. Therefore, we have to use policyDraft until policy has been set in Onyx. - const policy = policyDraft?.id ? policyDraft : policyProp; - const isPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); - const outputCurrency = policy?.outputCurrency ?? ''; - const currencySymbol = currencyList?.[outputCurrency]?.symbol ?? ''; - const formattedCurrency = !isEmptyObject(policy) && !isEmptyObject(currencyList) ? `${outputCurrency} - ${currencySymbol}` : ''; - - // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. - const workspaceAccountID = policy?.id ? PolicyUtils.getWorkspaceAccountID(policy.id) : CONST.DEFAULT_NUMBER_ID; - const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); - const hasCardFeedOrExpensifyCard = - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.workspaceAccountID); - - const [street1, street2] = (policy?.address?.addressStreet ?? '').split('\n'); - const formattedAddress = - !isEmptyObject(policy) && !isEmptyObject(policy.address) - ? `${street1?.trim()}, ${street2 ? `${street2.trim()}, ` : ''}${policy.address.city}, ${policy.address.state} ${policy.address.zipCode ?? ''}` - : ''; - - const onPressCurrency = useCallback(() => { - if (!policy?.id) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_CURRENCY.getRoute(policy.id)); - }, [policy?.id]); - const onPressAddress = useCallback(() => { - if (!policy?.id) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_ADDRESS.getRoute(policy.id)); - }, [policy?.id]); - const onPressName = useCallback(() => { - if (!policy?.id) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_NAME.getRoute(policy.id)); - }, [policy?.id]); - const onPressDescription = useCallback(() => { - if (!policy?.id) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy.id)); - }, [policy?.id]); - const onPressShare = useCallback(() => { - if (!policy?.id) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_SHARE.getRoute(policy.id)); - }, [policy?.id]); - const onPressPlanType = useCallback(() => { - if (!policy?.id) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_PROFILE_PLAN.getRoute(policy.id)); - }, [policy?.id]); - const policyName = policy?.name ?? ''; - const policyDescription = - // policy?.description can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - policy?.description || - Parser.replace( - translate('workspace.common.welcomeNote', { - workspaceName: policy?.name ?? '', - }), - ); - const readOnly = !PolicyUtils.isPolicyAdmin(policy); - const isOwner = PolicyUtils.isPolicyOwner(policy, currentUserAccountID); - const imageStyle: StyleProp = shouldUseNarrowLayout ? [styles.mhv12, styles.mhn5, styles.mbn5] : [styles.mhv8, styles.mhn8, styles.mbn5]; - const shouldShowAddress = !readOnly || !!formattedAddress; - - const fetchPolicyData = useCallback(() => { - if (policyDraft?.id) { - return; - } - Policy.openPolicyProfilePage(route.params.policyID); - }, [policyDraft?.id, route.params.policyID]); - - useNetwork({onReconnect: fetchPolicyData}); - - // We have the same focus effect in the WorkspaceInitialPage, this way we can get the policy data in narrow - // as well as in the wide layout when looking at policy settings. - useFocusEffect( - useCallback(() => { - fetchPolicyData(); - }, [fetchPolicyData]), - ); - - const DefaultAvatar = useCallback( - () => ( - - ), - [policy?.avatarURL, policy?.id, policyName, styles.alignSelfCenter, styles.avatarXLarge], - ); - - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - - const confirmDeleteAndHideModal = useCallback(() => { - if (!policy?.id || !policyName) { - return; - } - - Policy.deleteWorkspace(policy.id, policyName); - setIsDeleteModalOpen(false); - - // If the workspace being deleted is the active workspace, switch to the "All Workspaces" view - if (activeWorkspaceID === policy.id) { - setActiveWorkspaceID(undefined); - Navigation.dismissModal(); - const rootState = navigationRef.current?.getRootState() as State; - const topmostBottomTabRoute = getTopmostBottomTabRoute(rootState); - if (topmostBottomTabRoute?.name === SCREENS.SETTINGS.ROOT) { - Navigation.setParams({policyID: undefined}, topmostBottomTabRoute?.key); - } - } - }, [policy?.id, policyName, activeWorkspaceID, setActiveWorkspaceID]); - - const mentionReportContextValue = useMemo(() => ({policyID: policy?.id, currentReportID: undefined}), [policy]); - - return ( - - {(hasVBA?: boolean) => ( - -
- - { - if (!policy?.id) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy.id)); - }} - source={policy?.avatarURL ?? ''} - avatarID={policy?.id} - size={CONST.AVATAR_SIZE.XLARGE} - avatarStyle={styles.avatarXLarge} - enablePreview - DefaultAvatar={DefaultAvatar} - type={CONST.ICON_TYPE_WORKSPACE} - fallbackIcon={Expensicons.FallbackWorkspaceAvatar} - style={[ - policy?.errorFields?.avatarURL ?? shouldUseNarrowLayout ? styles.mb1 : styles.mb3, - shouldUseNarrowLayout ? styles.mtn17 : styles.mtn20, - styles.alignItemsStart, - styles.sectionMenuItemTopDescription, - ]} - editIconStyle={styles.smallEditIconWorkspace} - isUsingDefaultAvatar={!policy?.avatarURL ?? false} - onImageSelected={(file) => { - if (!policy?.id) { - return; - } - Policy.updateWorkspaceAvatar(policy.id, file as File); - }} - onImageRemoved={() => { - if (!policy?.id) { - return; - } - Policy.deleteWorkspaceAvatar(policy.id); - }} - editorMaskImage={Expensicons.ImageCropSquareMask} - pendingAction={policy?.pendingFields?.avatarURL} - errors={policy?.errorFields?.avatarURL} - onErrorClose={() => { - if (!policy?.id) { - return; - } - Policy.clearAvatarErrors(policy.id); - }} - previewSource={UserUtils.getFullSizeAvatar(policy?.avatarURL ?? '')} - headerTitle={translate('workspace.common.workspaceAvatar')} - originalFileName={policy?.originalFileName} - disabled={readOnly} - disabledStyle={styles.cursorDefault} - errorRowStyles={styles.mt3} - /> - - - - {(!StringUtils.isEmptyString(policy?.description ?? '') || !readOnly) && ( - { - if (!policy?.id) { - return; - } - Policy.clearPolicyErrorField(policy.id, CONST.POLICY.COLLECTION_KEYS.DESCRIPTION); - }} - > - - - - - )} - { - if (!policy?.id) { - return; - } - Policy.clearPolicyErrorField(policy.id, CONST.POLICY.COLLECTION_KEYS.GENERAL_SETTINGS); - }} - errorRowStyles={[styles.mt2]} - > - - - - - {!!canUseSpotnanaTravel && shouldShowAddress && ( - - - - - - )} - - {!readOnly && !!policy?.type && ( - - - - - - )} - {!readOnly && ( - - {isPolicyAdmin && ( -
- setIsDeleteModalOpen(false)} - prompt={hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> -
- )} -
- ); -} - -WorkspaceProfilePage.displayName = 'WorkspaceProfilePage'; - -export default withPolicy(WorkspaceProfilePage); From 4a77563c7ffea893bb4b6033405d7c776f5fc68e Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 3 Dec 2025 01:11:48 +0700 Subject: [PATCH 004/527] fix: add provider --- src/pages/workspace/WorkspaceOverviewPage.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 3042985e4e65e..e32b092dd0b06 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -1,5 +1,5 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {ImageStyle, StyleProp} from 'react-native'; import {Image, StyleSheet, View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -8,6 +8,7 @@ import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import ConfirmModal from '@components/ConfirmModal'; +import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import {LockedAccountContext} from '@components/LockedAccountModalProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -468,6 +469,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa return renderDropdownMenu(secondaryActions); }; + const mentionReportContextValue = useMemo(() => ({policyID: policy?.id, currentReportID: undefined}), [policy]); + return ( - + + +
)} Date: Wed, 3 Dec 2025 01:43:34 +0700 Subject: [PATCH 005/527] fix: optimistic case --- .../HTMLRenderers/MentionReportRenderer/index.tsx | 2 +- src/libs/MentionUtils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index 4cac22577b268..481ecc343ceaa 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -38,7 +38,7 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender [currentReport, policyID], ); - const mentionDetails = getReportMentionDetails(htmlAttributeReportID, currentReport, reports, tnode); + const mentionDetails = getReportMentionDetails(htmlAttributeReportID, currentReport, reports, tnode, policyID); if (!mentionDetails) { return null; } diff --git a/src/libs/MentionUtils.ts b/src/libs/MentionUtils.ts index 818d3f899cfa1..a945100970881 100644 --- a/src/libs/MentionUtils.ts +++ b/src/libs/MentionUtils.ts @@ -9,7 +9,7 @@ import {isChatRoom} from './ReportUtils'; const removeLeadingLTRAndHash = (value: string) => value.replace(CONST.UNICODE.LTR, '').replace('#', ''); -const getReportMentionDetails = (htmlAttributeReportID: string, currentReport: OnyxEntry, reports: OnyxCollection, tnode: TText | TPhrasing) => { +const getReportMentionDetails = (htmlAttributeReportID: string, currentReport: OnyxEntry, reports: OnyxCollection, tnode: TText | TPhrasing, policyID?: string) => { let reportID: string | undefined; let mentionDisplayText: string; @@ -23,7 +23,7 @@ const getReportMentionDetails = (htmlAttributeReportID: string, currentReport: O mentionDisplayText = removeLeadingLTRAndHash(tnode.data); for (const report of Object.values(reports ?? {})) { - if (report?.policyID !== currentReport?.policyID || !isChatRoom(report) || removeLeadingLTRAndHash(report?.reportName ?? '') !== mentionDisplayText) { + if (report?.policyID !== (currentReport?.policyID ?? policyID) || !isChatRoom(report) || removeLeadingLTRAndHash(report?.reportName ?? '') !== mentionDisplayText) { continue; } reportID = report?.reportID; From d7a1e21fe92d89b082566ca5afe7aa955cc75686 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 3 Dec 2025 01:44:17 +0700 Subject: [PATCH 006/527] fix: redundant change --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 80b39b780a4f8..7980c90b37005 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 80b39b780a4f8e466892968cdc665491fba9b64e +Subproject commit 7980c90b370052b90323ed13d1297c881f178693 From 44c4f9a5d7daa5351b4e16829b708e0dbda69d59 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 9 Dec 2025 15:41:30 +0700 Subject: [PATCH 007/527] fix: lint --- src/pages/workspace/WorkspaceOverviewPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 5b8818e5e2867..54232855ce36d 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -468,7 +468,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa return renderDropdownMenu(secondaryActions); }; - const mentionReportContextValue = useMemo(() => ({policyID: policy?.id, currentReportID: undefined}), [policy]); + const mentionReportContextValue = useMemo(() => ({policyID: policy?.id, currentReportID: undefined}), [policy?.id]); const modals = ( <> Date: Tue, 9 Dec 2025 15:45:19 +0700 Subject: [PATCH 008/527] fix: apply suggestion --- .../HTMLRenderers/MentionReportRenderer/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index 481ecc343ceaa..4f66da5512f49 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -34,7 +34,7 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender // When we invite someone to a room they don't have the policy object, but we still want them to be able to see and click on report mentions, so we only check if the policyID in the report is from a workspace const isGroupPolicyReport = useMemo( - () => policyID ?? (currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE), + () => (!!currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE) || !!policyID, [currentReport, policyID], ); From 8e0ebe0592f17186f3c5e9a3da73e6cf2299d567 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 9 Dec 2025 15:46:46 +0700 Subject: [PATCH 009/527] fix: remove redundant change --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 7980c90b37005..43b14ca7a22d5 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 7980c90b370052b90323ed13d1297c881f178693 +Subproject commit 43b14ca7a22d5c682f31dc86b7339d6b3280ff96 From 75298de21e94bb128a646bf79fc26e06371e8bde Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 9 Dec 2025 15:59:32 +0700 Subject: [PATCH 010/527] fix: add canBeMissing prop --- .../HTMLRenderers/MentionReportRenderer/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index 4f66da5512f49..a9d3e9c454a99 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -25,12 +25,12 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender const StyleUtils = useStyleUtils(); const htmlAttributeReportID = tnode.attributes.reportid; const {currentReportID: currentReportIDContext, exactlyMatch, policyID} = useContext(MentionReportContext); - const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); const currentReportID = useCurrentReportID(); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const currentReportIDValue = currentReportIDContext || currentReportID?.currentReportID; - const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportIDValue}`); + const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportIDValue}`, {canBeMissing: true}); // When we invite someone to a room they don't have the policy object, but we still want them to be able to see and click on report mentions, so we only check if the policyID in the report is from a workspace const isGroupPolicyReport = useMemo( From d0770531851cf93dd69698fbf1049ec942b5fe3c Mon Sep 17 00:00:00 2001 From: truph01 Date: Mon, 5 Jan 2026 12:24:23 +0700 Subject: [PATCH 011/527] fix: remove redundant change --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 43b14ca7a22d5..8680b6b67c5a8 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 43b14ca7a22d5c682f31dc86b7339d6b3280ff96 +Subproject commit 8680b6b67c5a8ec7ffe46c2578ce9f49e8743265 From 8de20a3590356bfda2af8ce3991a6d147de13979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 9 Feb 2026 10:41:08 +0100 Subject: [PATCH 012/527] Groups filter on members page POC --- .../Search/FilterDropdowns/DropdownButton.tsx | 52 +++++----- .../FilterDropdowns/SingleSelectPopup.tsx | 8 +- .../ListItem/SingleSelectListItem.tsx | 2 +- src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/pages/domain/Admins/DomainAdminsPage.tsx | 9 ++ src/pages/domain/BaseDomainMembersPage.tsx | 46 +++++---- src/pages/domain/Groups/DomainGroupsPage.tsx | 6 +- .../domain/Members/DomainMembersPage.tsx | 97 ++++++++++++++++++- 9 files changed, 170 insertions(+), 52 deletions(-) diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index fcdc1cd559460..d954c2840b9a5 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -69,6 +69,7 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi const triggerRef = useRef(null); const anchorRef = useRef(null); const [isOverlayVisible, setIsOverlayVisible] = useState(false); + const [shouldMountPopover, setShouldMountPopover] = useState(false); const {calculatePopoverPosition} = usePopoverPosition(); const [popoverTriggerPosition, setPopoverTriggerPosition] = useState({ @@ -97,6 +98,7 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi const calculatePopoverPositionAndToggleOverlay = useCallback(() => { calculatePopoverPosition(anchorRef, ANCHOR_ORIGIN).then((pos) => { setPopoverTriggerPosition({...pos, vertical: pos.vertical + PADDING_MODAL}); + setShouldMountPopover(true); toggleOverlay(); }); }, [calculatePopoverPosition, toggleOverlay]); @@ -152,30 +154,32 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi - {/* Dropdown overlay */} - - {popoverContent} - + {/* Dropdown overlay - lazily mounted to avoid hidden FlashList interfering with page layout */} + {shouldMountPopover && ( + + {popoverContent} + + )} ); } diff --git a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx index b16d6311fbe26..cbca538f29dc5 100644 --- a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native'; import Button from '@components/Button'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import type {ListItem} from '@components/SelectionList/types'; +import type {ListItem, SelectionListStyle} from '@components/SelectionList/types'; import Text from '@components/Text'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; @@ -40,9 +40,12 @@ type SingleSelectPopupProps = { /** The default value to set when reset is clicked */ defaultValue?: string; + + /** Custom styles for the SelectionList */ + selectionListStyle?: SelectionListStyle; }; -function SingleSelectPopup({label, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder, defaultValue}: SingleSelectPopupProps) { +function SingleSelectPopup({label, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder, defaultValue, selectionListStyle}: SingleSelectPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -123,6 +126,7 @@ function SingleSelectPopup({label, value, items, closeOverlay, ListItem={SingleSelectListItem} onSelectRow={updateSelectedItem} textInputOptions={textInputOptions} + style={selectionListStyle} shouldUpdateFocusedIndex={isSearchable} initiallyFocusedItemKey={isSearchable ? value?.value : undefined} showLoadingPlaceholder={!noResultsFound} diff --git a/src/components/SelectionList/ListItem/SingleSelectListItem.tsx b/src/components/SelectionList/ListItem/SingleSelectListItem.tsx index 77830533f0d3a..b1fd53063dde7 100644 --- a/src/components/SelectionList/ListItem/SingleSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/SingleSelectListItem.tsx @@ -55,7 +55,7 @@ function SingleSelectListItem({ alternateTextNumberOfLines={alternateTextNumberOfLines} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} - wrapperStyle={[wrapperStyle, styles.optionRowCompact]} + wrapperStyle={[styles.optionRowCompact, wrapperStyle]} titleStyles={titleStyles} shouldHighlightSelectedItem={shouldHighlightSelectedItem} /> diff --git a/src/languages/en.ts b/src/languages/en.ts index ff5d0a1fcb066..12f66e6a74f3d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8389,6 +8389,7 @@ const translations = { title: 'Members', findMember: 'Find member', addMember: 'Add member', + allMembers: 'All members', email: 'Email address', closeAccount: 'Close account', closeAccountPrompt: 'Are you sure? This action is permanent.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 115ce6ad747c0..3304ff16144f3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8553,6 +8553,7 @@ ${amount} para ${merchant} - ${date}`, title: 'Miembros', findMember: 'Buscar miembro', addMember: 'Añadir miembro', + allMembers: 'Todos los miembros', email: 'Dirección de correo electrónico', closeAccount: 'Cerrar cuenta', closeAccountPrompt: '¿Estás seguro? Esta acción es permanente.', diff --git a/src/pages/domain/Admins/DomainAdminsPage.tsx b/src/pages/domain/Admins/DomainAdminsPage.tsx index 7ab5949017c2f..eb2fa5a92370d 100644 --- a/src/pages/domain/Admins/DomainAdminsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminsPage.tsx @@ -2,6 +2,7 @@ import {adminAccountIDsSelector, adminPendingActionSelector, technicalContactSet import React from 'react'; import Badge from '@components/Badge'; import Button from '@components/Button'; +import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -64,6 +65,13 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { pendingAction: domainPendingAction?.[accountID]?.pendingAction, }); + const getCustomListHeader = () => ( + + ); + const headerContent = isAdmin ? ( <> - {/* Dropdown overlay - lazily mounted to avoid hidden FlashList interfering with page layout */} - {shouldMountPopover && ( - - {popoverContent} - - )} + {/* Dropdown overlay */} + + {popoverContent} + ); } diff --git a/src/pages/domain/BaseDomainMembersPage.tsx b/src/pages/domain/BaseDomainMembersPage.tsx index daf4042e3c21a..31360e8b62f67 100644 --- a/src/pages/domain/BaseDomainMembersPage.tsx +++ b/src/pages/domain/BaseDomainMembersPage.tsx @@ -192,9 +192,14 @@ function BaseDomainMembersPage({ const showSearchBar = data.length > CONST.SEARCH_ITEM_LIMIT; const listHeaderContent = ( - + + + {searchBarAccessory} + {showSearchBar ? ( - + ) : undefined} - {searchBarAccessory} ); @@ -226,7 +230,7 @@ function BaseDomainMembersPage({ {!shouldUseNarrowLayout && !!headerContent && {headerContent}} - {shouldUseNarrowLayout && !!headerContent && {headerContent}} + {shouldUseNarrowLayout && !!headerContent && {headerContent}} Date: Fri, 20 Feb 2026 11:01:02 +0100 Subject: [PATCH 033/527] Freeze screen below focused on mobile --- ...+7.3.14+003+freeze-screen-below-focused.patch | 16 ++++++++++++++++ patches/react-navigation/details.md | 10 +++++++++- src/setup/index.ts | 5 +++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch diff --git a/patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch b/patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch new file mode 100644 index 0000000000000..21c056fe388ed --- /dev/null +++ b/patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch @@ -0,0 +1,16 @@ +diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/NativeStackView.native.js b/node_modules/@react-navigation/native-stack/lib/module/views/NativeStackView.native.js +--- a/node_modules/@react-navigation/native-stack/lib/module/views/NativeStackView.native.js ++++ b/node_modules/@react-navigation/native-stack/lib/module/views/NativeStackView.native.js +@@ -376,9 +376,9 @@ + const isModal = modalRouteKeys.includes(route.key); + const isPreloaded = preloadedDescriptors[route.key] !== undefined && descriptors[route.key] === undefined; + +- // On Fabric, when screen is frozen, animated and reanimated values are not updated +- // due to component being unmounted. To avoid this, we don't freeze the previous screen there +- const shouldFreeze = isFabric() ? !isPreloaded && !isFocused && !isBelowFocused : !isPreloaded && !isFocused; ++ // Freezing the screen below the focused one is safe on Fabric because ++ // DelayedFreeze defers it to the next macrotask, and transition animations run on the UI thread. ++ const shouldFreeze = !isPreloaded && !isFocused; + return /*#__PURE__*/_jsx(SceneView, { + index: index, + focused: isFocused, \ No newline at end of file diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 353fac20ab40b..5d7ef976c7b8f 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -39,6 +39,14 @@ - PR Introducing Patch: [#37891](https://github.com/Expensify/App/pull/37891) - PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155) +### [@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch](@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch) + +- Reason: Removes the `isBelowFocused` exception on Fabric that prevented freezing the screen directly below the focused one. This is now safe because `DelayedFreeze` in `react-native-screens` defers freezing to the next macrotask, and native-stack transition animations run on the UI thread independently of the React tree. +- Upstream PR/issue: N/A +- E/App issue: N/A +- PR Introducing Patch: https://github.com/Expensify/App/pull/82764 +- PR Updating Patch: N/A + ### [@react-navigation+native+7.1.10+001+initial.patch](@react-navigation+native+7.1.10+001+initial.patch) - Reason: Allows us to use some more advanced navigation actions without messing up the browser history @@ -75,4 +83,4 @@ - Upstream PR/issue: N/A - E/App issue: [#65709](https://github.com/Expensify/App/issues/65211) - PR Introducing Patch: [#65836](https://github.com/Expensify/App/pull/66890) -- PR Updating Patch: N/A \ No newline at end of file +- PR Updating Patch: N/A diff --git a/src/setup/index.ts b/src/setup/index.ts index 1c8f5655ff0d1..87c461a694421 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -2,6 +2,7 @@ import toSortedPolyfill from 'array.prototype.tosorted'; import {I18nManager} from 'react-native'; import Config from 'react-native-config'; import Onyx from 'react-native-onyx'; +import {enableFreeze} from 'react-native-screens'; import intlPolyfill from '@libs/IntlPolyfill'; import {setDeviceID} from '@userActions/Device'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -14,6 +15,10 @@ import telemetry from './telemetry'; const enableDevTools = Config?.USE_REDUX_DEVTOOLS ? Config.USE_REDUX_DEVTOOLS === 'true' : true; export default function () { + // Enable screen freezing on mobile to prevent unnecessary re-renders on screens that are not visible to the user. + // This is a no-op on web — for web, we use ScreenFreezeWrapper in SplitNavigator instead. + enableFreeze(true); + telemetry(); toSortedPolyfill.shim(); From 69e7d056b7cd37b4bb7f063cfc961326fcb1d175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 23 Feb 2026 14:08:29 +0100 Subject: [PATCH 034/527] update useDomainGroupFilter to reset the filter state if group is removed while selected --- src/hooks/useDomainGroupFilter.ts | 15 +++++-- tests/unit/hooks/useDomainGroupFilter.test.ts | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/hooks/useDomainGroupFilter.ts b/src/hooks/useDomainGroupFilter.ts index 360192ce0e2dd..864c60d7b4b0a 100644 --- a/src/hooks/useDomainGroupFilter.ts +++ b/src/hooks/useDomainGroupFilter.ts @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useEffect, useState} from 'react'; import type {SingleSelectItem} from '@components/Search/FilterDropdowns/SingleSelectPopup'; import type {MemberOption} from '@pages/domain/BaseDomainMembersPage'; import {groupsSelector} from '@selectors/Domain'; @@ -51,9 +51,16 @@ function useDomainGroupFilter(domainAccountID: number): UseDomainGroupFilterResu const matchedGroup = selectedGroup && selectedGroup.value !== ALL_MEMBERS_VALUE ? groups?.find((g) => g.id === selectedGroup.value) : undefined; - // If the selected group no longer exists in Onyx data, reset to "All Members" - // to avoid a stale label while the filter is effectively inactive. - const effectiveSelection = selectedGroup && selectedGroup.value !== ALL_MEMBERS_VALUE && !matchedGroup ? null : selectedGroup; + // If the selected group disappears from Onyx (e.g. during rollback/refresh), clear the + // selection from state so it cannot silently reactivate if the same group ID reappears later. + useEffect(() => { + if (!selectedGroup || selectedGroup.value === ALL_MEMBERS_VALUE || matchedGroup) { + return; + } + setSelectedGroup(null); + }, [matchedGroup, selectedGroup]); + + const effectiveSelection = matchedGroup ? selectedGroup : null; const selectedGroupMemberIDs = matchedGroup ? new Set( diff --git a/tests/unit/hooks/useDomainGroupFilter.test.ts b/tests/unit/hooks/useDomainGroupFilter.test.ts index 428fba8fb8d85..5118d9f48ba3b 100644 --- a/tests/unit/hooks/useDomainGroupFilter.test.ts +++ b/tests/unit/hooks/useDomainGroupFilter.test.ts @@ -299,6 +299,46 @@ describe('useDomainGroupFilter', () => { expect(result.current.selectedGroup).toBeNull(); expect(result.current.groupPreFilter(buildMemberOption(100))).toBe(true); }); + + it('should not reactivate the filter when a previously removed group reappears with the same ID (rollback scenario)', async () => { + const domain = buildDomain({ + '1': {members: {'100': 'read'}, name: 'Engineering'}, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); + + const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); + + await waitFor(() => { + expect(result.current.groupOptions).toHaveLength(2); + }); + + // Select the group + act(() => { + result.current.handleGroupChange({text: 'Engineering', value: '1'}); + }); + expect(result.current.selectedGroup).not.toBeNull(); + + // Group disappears from Onyx (e.g. optimistic update removed or data cleared) + await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, buildDomain({})); + + await waitFor(() => { + expect(result.current.selectedGroup).toBeNull(); + }); + expect(result.current.dropdownLabel).toBe(result.current.allMembersLabel); + + // Group reappears with the same ID (rollback / re-sync) + await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); + + await waitFor(() => { + expect(result.current.groupOptions).toHaveLength(2); + }); + + // Filter must remain inactive — the previous selection was cleared from state + expect(result.current.selectedGroup).toBeNull(); + expect(result.current.dropdownLabel).toBe(result.current.allMembersLabel); + expect(result.current.groupPreFilter(buildMemberOption(100))).toBe(true); + expect(result.current.groupPreFilter(buildMemberOption(999))).toBe(true); + }); }); describe('groups', () => { From 453ae8767b4ebe46b5feb4ef3c8abec67e9b9303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 23 Feb 2026 17:28:09 +0100 Subject: [PATCH 035/527] fix prettier --- src/components/ConnectToNetSuiteFlow/index.tsx | 2 +- src/hooks/useDomainGroupFilter.ts | 4 ++-- src/pages/domain/Members/DomainMembersPage.tsx | 2 +- src/pages/workspace/accounting/netsuite/utils.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/ConnectToNetSuiteFlow/index.tsx b/src/components/ConnectToNetSuiteFlow/index.tsx index 28a87da7e8ced..52824a331fa6c 100644 --- a/src/components/ConnectToNetSuiteFlow/index.tsx +++ b/src/components/ConnectToNetSuiteFlow/index.tsx @@ -8,11 +8,11 @@ import {isAuthenticationError} from '@libs/actions/connections'; import {getAdminPoliciesConnectedToNetSuite} from '@libs/actions/Policy/Policy'; import Navigation from '@libs/Navigation/Navigation'; import {useAccountingContext} from '@pages/workspace/accounting/AccountingContext'; +import {getInitialSubPageForNetsuiteTokenInput} from '@pages/workspace/accounting/netsuite/utils'; import type {AnchorPosition} from '@styles/index'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {getInitialSubPageForNetsuiteTokenInput} from '@pages/workspace/accounting/netsuite/utils'; import type {ConnectToNetSuiteFlowProps} from './types'; function ConnectToNetSuiteFlow({policyID}: ConnectToNetSuiteFlowProps) { diff --git a/src/hooks/useDomainGroupFilter.ts b/src/hooks/useDomainGroupFilter.ts index 864c60d7b4b0a..f07cda2d8aa25 100644 --- a/src/hooks/useDomainGroupFilter.ts +++ b/src/hooks/useDomainGroupFilter.ts @@ -1,8 +1,8 @@ +import {groupsSelector} from '@selectors/Domain'; +import type {DomainSecurityGroupWithID} from '@selectors/Domain'; import {useEffect, useState} from 'react'; import type {SingleSelectItem} from '@components/Search/FilterDropdowns/SingleSelectPopup'; import type {MemberOption} from '@pages/domain/BaseDomainMembersPage'; -import {groupsSelector} from '@selectors/Domain'; -import type {DomainSecurityGroupWithID} from '@selectors/Domain'; import ONYXKEYS from '@src/ONYXKEYS'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; diff --git a/src/pages/domain/Members/DomainMembersPage.tsx b/src/pages/domain/Members/DomainMembersPage.tsx index 20a937da969f4..7acf6301cf577 100644 --- a/src/pages/domain/Members/DomainMembersPage.tsx +++ b/src/pages/domain/Members/DomainMembersPage.tsx @@ -8,6 +8,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPopup'; +import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import useDomainGroupFilter from '@hooks/useDomainGroupFilter'; @@ -31,7 +32,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {DomainMemberErrors} from '@src/types/onyx/DomainErrors'; -import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; type DomainMembersPageProps = PlatformStackScreenProps; diff --git a/src/pages/workspace/accounting/netsuite/utils.ts b/src/pages/workspace/accounting/netsuite/utils.ts index 1e47998a41f70..ada9c3b5122f5 100644 --- a/src/pages/workspace/accounting/netsuite/utils.ts +++ b/src/pages/workspace/accounting/netsuite/utils.ts @@ -1,10 +1,10 @@ +import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import {isAuthenticationError} from '@libs/actions/connections'; import {canUseProvincialTaxNetSuite, canUseTaxNetSuite} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import type {NetSuiteConnectionConfig, NetSuiteSubsidiary} from '@src/types/onyx/Policy'; -import {isAuthenticationError} from '@libs/actions/connections'; import type Policy from '@src/types/onyx/Policy'; -import type {OnyxEntry} from 'react-native-onyx'; function shouldHideReimbursedReportsSection(config?: NetSuiteConnectionConfig) { return config?.reimbursableExpensesExportDestination === CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY; From d1b102947beabe6773aa8071ccd322943e90c527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 23 Feb 2026 17:56:11 +0100 Subject: [PATCH 036/527] fix ts --- src/hooks/useDomainGroupFilter.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/hooks/useDomainGroupFilter.ts b/src/hooks/useDomainGroupFilter.ts index f07cda2d8aa25..909e5a04792b4 100644 --- a/src/hooks/useDomainGroupFilter.ts +++ b/src/hooks/useDomainGroupFilter.ts @@ -35,10 +35,7 @@ type UseDomainGroupFilterResult = { function useDomainGroupFilter(domainAccountID: number): UseDomainGroupFilterResult { const {translate} = useLocalize(); - const [groups] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - canBeMissing: true, - selector: groupsSelector, - }); + const [groups] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: groupsSelector}); const [selectedGroup, setSelectedGroup] = useState | null>(null); From 38572f34456229eac8e7a5f8f53642eb48ebf2b7 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 24 Feb 2026 13:12:31 +0700 Subject: [PATCH 037/527] Refactor ConfirmModal usage to useConfirmModal in Discard change modal --- .../index.native.ts | 49 ++++++++ .../useDiscardChangesConfirmation/index.ts} | 111 +++++++----------- .../useDiscardChangesConfirmation/types.ts | 7 ++ src/libs/Permissions.ts | 1 + .../index.native.tsx | 54 --------- .../step/DiscardChangesConfirmation/types.ts | 7 -- .../step/IOURequestStepDescription.tsx | 31 ++--- .../step/IOURequestStepDistanceOdometer.tsx | 20 ++-- .../request/step/IOURequestStepMerchant.tsx | 31 ++--- .../settings/Profile/Avatar/AvatarPage.tsx | 7 +- 10 files changed, 148 insertions(+), 170 deletions(-) create mode 100644 src/hooks/useDiscardChangesConfirmation/index.native.ts rename src/{pages/iou/request/step/DiscardChangesConfirmation/index.tsx => hooks/useDiscardChangesConfirmation/index.ts} (50%) create mode 100644 src/hooks/useDiscardChangesConfirmation/types.ts delete mode 100644 src/pages/iou/request/step/DiscardChangesConfirmation/index.native.tsx delete mode 100644 src/pages/iou/request/step/DiscardChangesConfirmation/types.ts diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts new file mode 100644 index 0000000000000..7935a58888409 --- /dev/null +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -0,0 +1,49 @@ +import type {NavigationAction} from '@react-navigation/native'; +import {useIsFocused, usePreventRemove} from '@react-navigation/native'; +import {useCallback, useRef, useState} from 'react'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import navigationRef from '@libs/Navigation/navigationRef'; +import type UseDiscardChangesConfirmationOptions from './types'; + +function useDiscardChangesConfirmation({getHasUnsavedChanges, isEnabled = true}: UseDiscardChangesConfirmationOptions) { + const {translate} = useLocalize(); + const isFocused = useIsFocused(); + const {showConfirmModal} = useConfirmModal(); + const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); + const blockedNavigationAction = useRef(undefined); + + const hasUnsavedChanges = isEnabled && isFocused && getHasUnsavedChanges(); + const shouldPrevent = hasUnsavedChanges && !shouldAllowNavigation; + + usePreventRemove( + shouldPrevent, + useCallback( + ({data}: {data: {action: NavigationAction}}) => { + blockedNavigationAction.current = data.action; + showConfirmModal({ + title: translate('discardChangesConfirmation.title'), + prompt: translate('discardChangesConfirmation.body'), + danger: true, + confirmText: translate('discardChangesConfirmation.confirmText'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + setShouldAllowNavigation(true); + if (blockedNavigationAction.current) { + navigationRef.current?.dispatch(blockedNavigationAction.current); + blockedNavigationAction.current = undefined; + } else { + navigationRef.current?.goBack(); + } + }); + }, + [showConfirmModal, translate], + ), + ); +} + +export default useDiscardChangesConfirmation; diff --git a/src/pages/iou/request/step/DiscardChangesConfirmation/index.tsx b/src/hooks/useDiscardChangesConfirmation/index.ts similarity index 50% rename from src/pages/iou/request/step/DiscardChangesConfirmation/index.tsx rename to src/hooks/useDiscardChangesConfirmation/index.ts index 40a2ab96c88c9..ff3bb9834cb46 100644 --- a/src/pages/iou/request/step/DiscardChangesConfirmation/index.tsx +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -1,24 +1,54 @@ import type {NavigationAction} from '@react-navigation/native'; import {useIsFocused, useNavigation} from '@react-navigation/native'; -import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; -import ConfirmModal from '@components/ConfirmModal'; +import {useCallback, useEffect, useRef} from 'react'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import useBeforeRemove from '@hooks/useBeforeRemove'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import navigationRef from '@libs/Navigation/navigationRef'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; -import type DiscardChangesConfirmationProps from './types'; +import type UseDiscardChangesConfirmationOptions from './types'; -function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel, isEnabled = true}: DiscardChangesConfirmationProps) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, isEnabled = true}: UseDiscardChangesConfirmationOptions) { const navigation = useNavigation>(); const isFocused = useIsFocused(); const {translate} = useLocalize(); - const [isVisible, setIsVisible] = useState(false); + const {showConfirmModal} = useConfirmModal(); const blockedNavigationAction = useRef(undefined); const shouldNavigateBack = useRef(false); - const isConfirmed = useRef(false); + + const navigateBack = useCallback(() => { + if (blockedNavigationAction.current) { + navigationRef.current?.dispatch(blockedNavigationAction.current); + return; + } + if (!shouldNavigateBack.current) { + return; + } + navigationRef.current?.goBack(); + }, []); + + const showDiscardModal = useCallback(() => { + showConfirmModal({ + title: translate('discardChangesConfirmation.title'), + prompt: translate('discardChangesConfirmation.body'), + danger: true, + confirmText: translate('discardChangesConfirmation.confirmText'), + cancelText: translate('common.cancel'), + shouldIgnoreBackHandlerDuringTransition: true, + }).then((result) => { + if (result.action === ModalActions.CONFIRM) { + setNavigationActionToMicrotaskQueue(navigateBack); + } else { + blockedNavigationAction.current = undefined; + shouldNavigateBack.current = false; + onCancel?.(); + } + }); + }, [showConfirmModal, translate, navigateBack, onCancel]); useBeforeRemove( useCallback( @@ -29,24 +59,22 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel, isEnabled = e.preventDefault(); blockedNavigationAction.current = e.data.action; - navigateAfterInteraction(() => setIsVisible((prev) => !prev)); + showDiscardModal(); }, - [getHasUnsavedChanges, isFocused, isEnabled], + [getHasUnsavedChanges, isFocused, isEnabled, showDiscardModal], ), isEnabled && isFocused, ); /** - * We cannot programmatically stop the browser's back navigation like react-navigation's beforeRemove - * Events like popstate and transitionStart are triggered AFTER the back navigation has already completed - * So we need to go forward to get back to the current page + * We cannot programmatically stop the browser's back navigation like react-navigation's beforeRemove. + * Events like popstate and transitionStart are triggered AFTER the back navigation has already completed. + * So we need to go forward to get back to the current page. */ useEffect(() => { if (!isEnabled || !isFocused) { return undefined; } - // transitionStart is triggered before the previous page is fully loaded so RHP sliding animation - // could be less "glitchy" when going back and forth between the previous and current pages const unsubscribe = navigation.addListener('transitionStart', ({data: {closing}}) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!getHasUnsavedChanges()) { @@ -57,63 +85,12 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel, isEnabled = window.history.go(1); return; } - // Navigation.navigate() rerenders the current page and resets its states window.history.go(1); - navigateAfterInteraction(() => setIsVisible((prev) => !prev)); + navigateAfterInteraction(showDiscardModal); }); return unsubscribe; - }, [navigation, getHasUnsavedChanges, isFocused, isEnabled]); - - useEffect(() => { - if ((isFocused && isEnabled) || !isVisible) { - return; - } - setIsVisible(false); - blockedNavigationAction.current = undefined; - shouldNavigateBack.current = false; - }, [isFocused, isVisible, isEnabled]); - - const navigateBack = useCallback(() => { - if (blockedNavigationAction.current) { - navigationRef.current?.dispatch(blockedNavigationAction.current); - return; - } - if (!shouldNavigateBack.current) { - return; - } - navigationRef.current?.goBack(); - }, []); - - return ( - { - isConfirmed.current = true; - setIsVisible(false); - }} - onCancel={() => { - setIsVisible(false); - blockedNavigationAction.current = undefined; - shouldNavigateBack.current = false; - }} - onModalHide={() => { - if (isConfirmed.current) { - isConfirmed.current = false; - setNavigationActionToMicrotaskQueue(navigateBack); - } else { - shouldNavigateBack.current = false; - onCancel?.(); - } - }} - shouldIgnoreBackHandlerDuringTransition - /> - ); + }, [navigation, getHasUnsavedChanges, isFocused, isEnabled, showDiscardModal]); } -export default memo(DiscardChangesConfirmation); +export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts new file mode 100644 index 0000000000000..7420f6cfdd990 --- /dev/null +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -0,0 +1,7 @@ +type UseDiscardChangesConfirmationOptions = { + getHasUnsavedChanges: () => boolean; + onCancel?: () => void; + isEnabled?: boolean; +}; + +export default UseDiscardChangesConfirmationOptions; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 7d0e831f31007..854e5e0093055 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -5,6 +5,7 @@ import type BetaConfiguration from '@src/types/onyx/BetaConfiguration'; // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { + return true; return !!betas?.includes(CONST.BETAS.ALL); } diff --git a/src/pages/iou/request/step/DiscardChangesConfirmation/index.native.tsx b/src/pages/iou/request/step/DiscardChangesConfirmation/index.native.tsx deleted file mode 100644 index da853ae17a960..0000000000000 --- a/src/pages/iou/request/step/DiscardChangesConfirmation/index.native.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type {NavigationAction} from '@react-navigation/native'; -import {useIsFocused, usePreventRemove} from '@react-navigation/native'; -import React, {memo, useCallback, useRef, useState} from 'react'; -import ConfirmModal from '@components/ConfirmModal'; -import useLocalize from '@hooks/useLocalize'; -import navigationRef from '@libs/Navigation/navigationRef'; -import type DiscardChangesConfirmationProps from './types'; - -function DiscardChangesConfirmation({getHasUnsavedChanges, isEnabled = true}: DiscardChangesConfirmationProps) { - const {translate} = useLocalize(); - const isFocused = useIsFocused(); - const [isVisible, setIsVisible] = useState(false); - const shouldAllowNavigation = useRef(false); - const blockedNavigationAction = useRef(undefined); - - const hasUnsavedChanges = isEnabled && isFocused && getHasUnsavedChanges(); - const shouldPrevent = hasUnsavedChanges && !shouldAllowNavigation.current; - - usePreventRemove( - shouldPrevent, - useCallback(({data}) => { - blockedNavigationAction.current = data.action; - setIsVisible(true); - }, []), - ); - - return ( - { - setIsVisible(false); - shouldAllowNavigation.current = true; - if (blockedNavigationAction.current) { - navigationRef.current?.dispatch(blockedNavigationAction.current); - blockedNavigationAction.current = undefined; - } else { - navigationRef.current?.goBack(); - } - }} - onCancel={() => { - setIsVisible(false); - blockedNavigationAction.current = undefined; - }} - shouldHandleNavigationBack - /> - ); -} - -export default memo(DiscardChangesConfirmation); diff --git a/src/pages/iou/request/step/DiscardChangesConfirmation/types.ts b/src/pages/iou/request/step/DiscardChangesConfirmation/types.ts deleted file mode 100644 index 95ee6cfa4da44..0000000000000 --- a/src/pages/iou/request/step/DiscardChangesConfirmation/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -type DiscardChangesConfirmationProps = { - getHasUnsavedChanges: () => boolean; - onCancel?: () => void; - isEnabled?: boolean; -}; - -export default DiscardChangesConfirmationProps; diff --git a/src/pages/iou/request/step/IOURequestStepDescription.tsx b/src/pages/iou/request/step/IOURequestStepDescription.tsx index 636aff4588c03..35a2d34461daf 100644 --- a/src/pages/iou/request/step/IOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/IOURequestStepDescription.tsx @@ -8,6 +8,7 @@ import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -30,7 +31,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/MoneyRequestDescriptionForm'; import type * as OnyxTypes from '@src/types/onyx'; -import DiscardChangesConfirmation from './DiscardChangesConfirmation'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -183,6 +183,21 @@ function IOURequestStepDescription({ return transaction?.category && policyCategories ? (policyCategories[transaction?.category]?.commentHint ?? '') : ''; }; + useDiscardChangesConfirmation({ + onCancel: () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + inputRef.current?.focus(); + }); + }, + getHasUnsavedChanges: () => { + if (isSaved) { + return false; + } + return currentDescription !== currentDescriptionInMarkdown; + }, + }); + return ( - { - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - inputRef.current?.focus(); - }); - }} - getHasUnsavedChanges={() => { - if (isSaved) { - return false; - } - return currentDescription !== currentDescriptionInMarkdown; - }} - /> ); } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 6da58cafcc39d..4ca90b1db2a97 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -12,6 +12,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; @@ -42,7 +43,6 @@ import SCREENS from '@src/SCREENS'; import type Transaction from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import DiscardChangesConfirmation from './DiscardChangesConfirmation'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -454,6 +454,15 @@ function IOURequestStepDistanceOdometer({ navigateToNextPage(); }; + useDiscardChangesConfirmation({ + isEnabled: shouldEnableDiscardConfirmation, + getHasUnsavedChanges: () => { + const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; + const hasImageChanges = transaction?.comment?.odometerStartImage !== initialStartImageRef.current || transaction?.comment?.odometerEndImage !== initialEndImageRef.current; + return hasReadingChanges || hasImageChanges; + }, + }); + return ( - { - const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; - const hasImageChanges = - transaction?.comment?.odometerStartImage !== initialStartImageRef.current || transaction?.comment?.odometerEndImage !== initialEndImageRef.current; - return hasReadingChanges || hasImageChanges; - }} - /> ); } diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index 1f08c8c1e62f6..a045964a81ded 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -6,6 +6,7 @@ import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -24,7 +25,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/MoneyRequestMerchantForm'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import DiscardChangesConfirmation from './DiscardChangesConfirmation'; import StepScreenWrapper from './StepScreenWrapper'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -141,6 +141,21 @@ function IOURequestStepMerchant({ shouldNavigateAfterSaveRef.current = true; }; + useDiscardChangesConfirmation({ + onCancel: () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + inputRef.current?.focus(); + }); + }, + getHasUnsavedChanges: () => { + if (isSaved) { + return false; + } + return currentMerchant !== initialMerchant; + }, + }); + return ( - { - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - inputRef.current?.focus(); - }); - }} - getHasUnsavedChanges={() => { - if (isSaved) { - return false; - } - return currentMerchant !== initialMerchant; - }} - /> ); } diff --git a/src/pages/settings/Profile/Avatar/AvatarPage.tsx b/src/pages/settings/Profile/Avatar/AvatarPage.tsx index a60abaa18eb6a..a70db9848cbda 100644 --- a/src/pages/settings/Profile/Avatar/AvatarPage.tsx +++ b/src/pages/settings/Profile/Avatar/AvatarPage.tsx @@ -13,6 +13,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useAvatarMenu from '@hooks/useAvatarMenu'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLetterAvatars from '@hooks/useLetterAvatars'; import useLocalize from '@hooks/useLocalize'; @@ -23,7 +24,6 @@ import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types import Navigation from '@libs/Navigation/Navigation'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import {getDefaultAvatarName, isLetterAvatar, isPresetAvatar} from '@libs/UserAvatarUtils'; -import DiscardChangesConfirmation from '@pages/iou/request/step/DiscardChangesConfirmation'; import {updateAvatar} from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -61,6 +61,10 @@ function ProfileAvatar() { const isDirty = imageData.uri !== '' || !!selected; + useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => !isSavingRef.current && isDirty, + }); + const avatarStyle = [styles.avatarXLarge, styles.alignSelfStart, styles.alignSelfCenter]; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -315,7 +319,6 @@ function ProfileAvatar() { imageType={cropImageData.type} buttonLabel={translate('avatarPage.upload')} /> - !isSavingRef.current && isDirty} /> ); } From 007187eec6a9379b39bd68a35744158a8aa8b419 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 24 Feb 2026 13:25:31 +0700 Subject: [PATCH 038/527] remove hardcode --- src/libs/Permissions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 854e5e0093055..7d0e831f31007 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -5,7 +5,6 @@ import type BetaConfiguration from '@src/types/onyx/BetaConfiguration'; // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { - return true; return !!betas?.includes(CONST.BETAS.ALL); } From 5cfe087b39c689dfe5dee291f951aaae8ebf8c20 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 24 Feb 2026 18:22:14 +0700 Subject: [PATCH 039/527] implementaion --- cspell.json | 3 +- src/ROUTES.ts | 10 +- src/SCREENS.ts | 1 - .../Context/stateReducer.ts | 6 +- .../config/scenarios/SetPinOrderCard.ts | 32 -- .../config/scenarios/SetPinOrderCard.tsx | 76 +++ .../config/scenarios/index.ts | 15 +- .../MultifactorAuthentication/config/types.ts | 2 +- .../ReportActionItem/IssueCardMessage.tsx | 7 +- ...tailsAndShipExpensifyCardsWithPINParams.ts | 7 +- src/libs/API/types.ts | 4 +- src/libs/CardUtils.ts | 1 - .../Biometrics/VALUES.ts | 5 + .../Biometrics/types.ts | 7 +- .../Navigators/RightModalNavigator.tsx | 507 +++++++++--------- src/libs/Navigation/linkingConfig/config.ts | 5 +- src/libs/Navigation/types.ts | 4 +- .../MultifactorAuthentication/index.ts | 40 ++ src/libs/actions/PersonalDetails.ts | 99 ++-- .../MissingPersonalDetailsContent.tsx | 29 +- .../MissingPersonalDetailsMagicCodePage.tsx | 8 +- .../MissingPersonalDetails/PinContext.tsx | 14 +- src/pages/MissingPersonalDetails/index.tsx | 17 +- .../subPages/Confirmation.tsx | 1 - .../MissingPersonalDetails/subPages/Pin.tsx | 13 +- src/pages/MissingPersonalDetails/types.ts | 2 +- .../config/scenarios/index.test.ts | 1 - 27 files changed, 472 insertions(+), 444 deletions(-) delete mode 100644 src/components/MultifactorAuthentication/config/scenarios/SetPinOrderCard.ts create mode 100644 src/components/MultifactorAuthentication/config/scenarios/SetPinOrderCard.tsx diff --git a/cspell.json b/cspell.json index 8b0d60fc35c3b..d437cc0460d9f 100644 --- a/cspell.json +++ b/cspell.json @@ -830,7 +830,8 @@ "pgrep", "skia", "canvaskit", - "Invoicify" + "Invoicify", + "UKEU" ], "ignorePaths": [ "src/languages/de.ts", diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cb3b52d605264..b9b4def97c920 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3356,15 +3356,7 @@ const ROUTES = { return `missing-personal-details/${cardID}/${subPage}${action ? `/${action}` : ''}` as const; }, }, - MISSING_PERSONAL_DETAILS_CONFIRM_MAGIC_CODE: { - route: 'missing-personal-details/:cardID?/confirm-magic-code', - getRoute: (cardID?: string) => { - if (!cardID) { - return 'missing-personal-details/confirm-magic-code' as const; - } - return `missing-personal-details/${cardID}/confirm-magic-code` as const; - }, - }, + MISSING_PERSONAL_DETAILS_CONFIRM_MAGIC_CODE: 'missing-personal-details/confirm-magic-code', POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: { route: 'workspaces/:policyID/accounting/netsuite/subsidiary-selector', getRoute: (policyID: string | undefined) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 343a183458a9c..2cb34a04bd612 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -951,7 +951,6 @@ const SCREENS = { MULTIFACTOR_AUTHENTICATION: { MAGIC_CODE: 'Multifactor_Authentication_Magic_Code', BIOMETRICS_TEST: 'Multifactor_Authentication_Biometrics_Test', - SET_PIN_ORDER_CARD: 'Multifactor_Authentication_Set_Pin_Order_Card', OUTCOME_SUCCESS: 'Multifactor_Authentication_Outcome_Success', OUTCOME_FAILURE: 'Multifactor_Authentication_Outcome_Failure', PROMPT: 'Multifactor_Authentication_Prompt', diff --git a/src/components/MultifactorAuthentication/Context/stateReducer.ts b/src/components/MultifactorAuthentication/Context/stateReducer.ts index 9d21ceb034267..f5a4e32078afd 100644 --- a/src/components/MultifactorAuthentication/Context/stateReducer.ts +++ b/src/components/MultifactorAuthentication/Context/stateReducer.ts @@ -1,4 +1,5 @@ import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; +import type {MultifactorAuthenticationScenarioConfig} from '@components/MultifactorAuthentication/config/types'; import CONST from '@src/CONST'; import type {Action, MultifactorAuthenticationState} from './types'; @@ -67,12 +68,13 @@ function stateReducer(state: MultifactorAuthenticationState, action: Action): Mu return {...state, authenticationMethod: action.payload}; case 'SET_SCENARIO_RESPONSE': return {...state, scenarioResponse: action.payload}; - case 'INIT': + case 'INIT': { return { ...DEFAULT_STATE, - scenario: MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[action.payload.scenario], + scenario: MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[action.payload.scenario] as MultifactorAuthenticationScenarioConfig, payload: action.payload.payload, }; + } case 'RESET': return DEFAULT_STATE; case 'REREGISTER': diff --git a/src/components/MultifactorAuthentication/config/scenarios/SetPinOrderCard.ts b/src/components/MultifactorAuthentication/config/scenarios/SetPinOrderCard.ts deleted file mode 100644 index 23216382a1465..0000000000000 --- a/src/components/MultifactorAuthentication/config/scenarios/SetPinOrderCard.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type {MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types'; -import {updatePersonalDetailsAndShipExpensifyCardsWithPIN} from '@libs/actions/PersonalDetails'; -import CONST from '@src/CONST'; -import SCREENS from '@src/SCREENS'; - -/** - * Configuration for the SET_PIN_ORDER_CARD multifactor authentication scenario. - * This scenario is used when a UK/EU cardholder sets their PIN during the card ordering process. - */ -export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS], - action: updatePersonalDetailsAndShipExpensifyCardsWithPIN, - screen: SCREENS.MULTIFACTOR_AUTHENTICATION.SET_PIN_ORDER_CARD, - pure: true, - OUTCOMES: { - success: { - headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', - }, - failure: { - headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', - }, - outOfTime: { - headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', - }, - noEligibleMethods: { - headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', - }, - unsupportedDevice: { - headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', - }, - }, -} as const satisfies MultifactorAuthenticationScenarioCustomConfig; diff --git a/src/components/MultifactorAuthentication/config/scenarios/SetPinOrderCard.tsx b/src/components/MultifactorAuthentication/config/scenarios/SetPinOrderCard.tsx new file mode 100644 index 0000000000000..b2353de72b639 --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/SetPinOrderCard.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { + DefaultClientFailureScreen, + DefaultServerFailureScreen, + NoEligibleMethodsFailureScreen, + UnsupportedDeviceFailureScreen, +} from '@components/MultifactorAuthentication/components/OutcomeScreen/FailureScreen/defaultScreens'; +import type { + MultifactorAuthenticationScenario, + MultifactorAuthenticationScenarioAdditionalParams, + MultifactorAuthenticationScenarioCustomConfig, +} from '@components/MultifactorAuthentication/config/types'; +import {setPersonalDetailsAndShipExpensifyCardsWithPIN} from '@libs/actions/MultifactorAuthentication'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +/** + * Payload type for the SET_PIN_ORDER_CARD scenario. + * Contains personal details and PIN required for UK/EU card ordering. + */ +type Payload = { + legalFirstName: string; + legalLastName: string; + phoneNumber: string; + addressCity: string; + addressStreet: string; + addressStreet2: string; + addressZip: string; + addressCountry: string; + addressState: string; + dob: string; + pin: string; + cardID: string; +}; + +/** + * Type guard to verify the payload is a SetPinOrderCard payload. + */ +function isSetPinOrderCardPayload(payload: MultifactorAuthenticationScenarioAdditionalParams | undefined): payload is Payload { + return !!payload && 'cardID' in payload && 'pin' in payload; +} + +/** + * Configuration for the SET_PIN_ORDER_CARD multifactor authentication scenario. + * This scenario is used when a UK/EU cardholder sets their PIN during the card ordering process. + * + * Callback behavior per design doc: + * - Success: Navigate to ExpensifyCardPage and return SKIP_OUTCOME_SCREEN + * - Invalid PIN/personal details error: Display error in UI and return SKIP_OUTCOME_SCREEN + * - Authentication failure: Return SHOW_OUTCOME_SCREEN to show failure screen + */ +export default { + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS], + action: setPersonalDetailsAndShipExpensifyCardsWithPIN, + + callback: async (isSuccessful, _callbackInput, payload) => { + if (isSuccessful && isSetPinOrderCardPayload(payload)) { + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD.getRoute(String(payload.cardID))); + return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN; + } + + // For authentication failures, show the outcome screen + return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN; + }, + + // Failure screens for different error scenarios + defaultClientFailureScreen: , + defaultServerFailureScreen: , + failureScreens: { + [CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.NO_ELIGIBLE_METHODS]: , + [CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE]: , + }, +} as const satisfies MultifactorAuthenticationScenarioCustomConfig; + +export type {Payload}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/index.ts b/src/components/MultifactorAuthentication/config/scenarios/index.ts index bc7ce37e7d96e..2f990f7480c54 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/index.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/index.ts @@ -3,20 +3,17 @@ import type {MultifactorAuthenticationScenarioConfigRecord} from '@components/Mu import CONST from '@src/CONST'; import BiometricsTest from './BiometricsTest'; import {customConfig} from './DefaultUserInterface'; +import type {Payload as SetPinOrderCardPayload} from './SetPinOrderCard'; import SetPinOrderCard from './SetPinOrderCard'; /** * Payload types for multifactor authentication scenarios. - * Since the BiometricsTest does not require any payload, it is an empty object for now. - * The AuthorizeTransaction Scenario will change it, as it needs the transactionID to be provided as well. - * - * { - * "AUTHORIZE-TRANSACTION": { - * transactionID: string; - * } - * } + * Each scenario that requires additional parameters should have its payload type defined here. */ -type Payloads = EmptyObject; +type Payloads = { + [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]: EmptyObject; + [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]: SetPinOrderCardPayload; +}; /** * Configuration records for all multifactor authentication scenarios. diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index bf3c66d46a518..3336aab9ded2e 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -89,7 +89,7 @@ type MultifactorAuthenticationScenarioPureMethod = EmptyObject> = { action: MultifactorAuthenticationScenarioPureMethod; allowedAuthenticationMethods: Array>; - screen: MultifactorAuthenticationScreen; + screen?: MultifactorAuthenticationScreen; /** * Whether the scenario does not require any additional parameters except for the native biometrics data. diff --git a/src/components/ReportActionItem/IssueCardMessage.tsx b/src/components/ReportActionItem/IssueCardMessage.tsx index bd1c0cecd557e..8a7b02bb04a90 100644 --- a/src/components/ReportActionItem/IssueCardMessage.tsx +++ b/src/components/ReportActionItem/IssueCardMessage.tsx @@ -48,7 +48,12 @@ function IssueCardMessage({action, policyID, shouldNavigateToCardDetails}: Issue /> {shouldShowAddMissingDetailsButton && (