From 0a55551bbce7b5448d56fe78e03bf9c7cc0616c0 Mon Sep 17 00:00:00 2001 From: "Maruf Sharifi (via MelvinBot)" Date: Tue, 13 Oct 2026 10:48:22 +0000 Subject: [PATCH 1/6] Resolve stale optimistic accountID on workspace member Role page After inviting a member, the Role page route still holds the optimistic accountID. Once AddMembersToWorkspace finishes, that personal details entry is removed, so the page lost the member and showed the not found view. Share the optimistic-ID fallback from the member details page and use it on the Role, Work arrangement, and custom field pages. Co-authored-by: Maruf Sharifi --- src/libs/PolicyUtils/index.ts | 18 ++ .../workspace/members/WorkArrangementPage.tsx | 9 +- .../WorkspaceMemberCustomFieldPage.tsx | 8 +- .../members/WorkspaceMemberDetailsPage.tsx | 21 +-- .../WorkspaceMemberDetailsRolePage.tsx | 9 +- .../ui/WorkspaceMemberDetailsRolePageTest.tsx | 172 ++++++++++++++++++ 6 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 tests/ui/WorkspaceMemberDetailsRolePageTest.tsx diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index d950919124ed..16c8aaee7d5a 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -754,6 +754,23 @@ function getAccountIDForSubmitManagerEmail( return undefined; } +/** + * A member who was just invited is listed under an optimistic accountID derived from their login, and that entry is removed from + * personal details once the invite request finishes and the backend supplies the real accountID. A member page opened + * while the invite was still in flight therefore holds a stale accountID in its route. Recover the login from the employee list + * so the page keeps resolving the member across that swap. + */ +function getMemberLoginByOptimisticAccountID(policy: OnyxEntry, accountID: number) { + const matchesOptimisticAccountID = (login: string) => generateAccountID(login) === accountID || generateAccountID(Str.removeSMSDomain(login)) === accountID; + // A member invited by one of their secondary logins ends up listed under their primary login, so a route ID derived + // from the secondary matches no employee — primaryLoginsInvited records that mapping and recovers the member. + const primaryLoginOfInvitedSecondary = Object.entries(policy?.primaryLoginsInvited ?? {}).find(([secondaryLogin]) => matchesOptimisticAccountID(secondaryLogin))?.[1]; + if (primaryLoginOfInvitedSecondary) { + return primaryLoginOfInvitedSecondary; + } + return Object.keys(policy?.employeeList ?? {}).find(matchesOptimisticAccountID) ?? ''; +} + /** * Get login list that we should not show in the workspace invite options */ @@ -2237,6 +2254,7 @@ export { getIneligibleInvitees, getExcludedUsers, getMemberAccountIDsForWorkspace, + getMemberLoginByOptimisticAccountID, getAccountIDForSubmitManagerEmail, getGuideAndAccountManagerInfo, getSoftExclusionsForGuideAndAccountManager, diff --git a/src/pages/workspace/members/WorkArrangementPage.tsx b/src/pages/workspace/members/WorkArrangementPage.tsx index 4d9ff8fdd9f8..b2c6080f3192 100644 --- a/src/pages/workspace/members/WorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkArrangementPage.tsx @@ -8,13 +8,14 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; +import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useThemeStyles from '@hooks/useThemeStyles'; import {setEmployeeWorkArrangement} from '@libs/actions/Policy/DistanceRate'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberWrite, isMemberInHomeAndOfficeWorkspace} from '@libs/PolicyUtils'; +import {canMemberWrite, getMemberLoginByOptimisticAccountID, isMemberInHomeAndOfficeWorkspace} from '@libs/PolicyUtils'; import {getEffectiveWorkArrangement, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -48,7 +49,7 @@ type WorkArrangementPageProps = Omit, accountID: number) { - const matchesOptimisticAccountID = (login: string) => generateAccountID(login) === accountID || generateAccountID(Str.removeSMSDomain(login)) === accountID; - // A member invited by one of their secondary logins ends up listed under their primary login, so a route ID derived - // from the secondary matches no employee — primaryLoginsInvited records that mapping and recovers the member. - const primaryLoginOfInvitedSecondary = Object.entries(policy?.primaryLoginsInvited ?? {}).find(([secondaryLogin]) => matchesOptimisticAccountID(secondaryLogin))?.[1]; - if (primaryLoginOfInvitedSecondary) { - return primaryLoginOfInvitedSecondary; - } - return Object.keys(policy?.employeeList ?? {}).find(matchesOptimisticAccountID) ?? ''; -} - function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceMemberDetailsPageProps) { const policyID = route.params.policyID; const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 6cf74e079b4c..b994bac23d96 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -3,6 +3,7 @@ import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; @@ -12,7 +13,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getAllowedRolesForMember, isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; -import {canMemberAssignRole, canRolePay} from '@libs/PolicyUtils'; +import {canMemberAssignRole, canRolePay, getMemberLoginByOptimisticAccountID} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -33,11 +34,13 @@ type WorkspaceMemberDetailsRolePageProps = Omit { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/actions/Policy/Member'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + updateWorkspaceMembersRole: jest.fn(), + }; +}); + +TestHelper.setupGlobalFetchMock(); + +const updateWorkspaceMembersRoleMock = jest.mocked(updateWorkspaceMembersRole); + +const Stack = createPlatformStackNavigator(); + +const renderPage = (initialParams: SettingsNavigatorParamList[typeof SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE]) => { + return render( + + + + + + + + + , + ); +}; + +describe('WorkspaceMemberDetailsRolePage', () => { + const ownerAccountID = 1; + const ownerEmail = 'owner@example.com'; + const selfAccountID = 1206; + const selfEmail = 'self@example.com'; + const invitedAccountID = 5555; + const invitedEmail = 'newmember@example.com'; + + const policy = { + ...LHNTestUtils.getFakePolicy(), + role: CONST.POLICY.ROLE.ADMIN, + owner: ownerEmail, + ownerAccountID, + type: CONST.POLICY.TYPE.CORPORATE, + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN}, + [selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.ADMIN}, + [invitedEmail]: {email: invitedEmail, role: CONST.POLICY.ROLE.USER}, + }, + }; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(async () => { + await TestHelper.signInWithTestUser(selfAccountID, selfEmail, undefined, 'Self'); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(`${ONYXKEYS.PERSONAL_DETAILS_LIST}`, { + [ownerAccountID]: TestHelper.buildPersonalDetails(ownerEmail, ownerAccountID, 'Owner'), + [selfAccountID]: TestHelper.buildPersonalDetails(selfEmail, selfAccountID, 'Self'), + [invitedAccountID]: TestHelper.buildPersonalDetails(invitedEmail, invitedAccountID, 'Invited'), + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + }); + const responsiveLayout: ResponsiveLayoutResult = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isInLandscapeMode: false, + }; + jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue(responsiveLayout); + jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should keep showing the role list when the route holds a stale optimistic accountID', async () => { + // Given a Role page opened while the invite was in flight, so its route accountID is the login-derived + // optimistic one, which no longer has a personal details entry after the backend swaps in the real accountID + const staleOptimisticAccountID = generateAccountID(invitedEmail); + + // When the page renders + const {unmount} = renderPage({policyID: policy.id, accountID: String(staleOptimisticAccountID)}); + await waitForBatchedUpdatesWithAct(); + + // Then the member is still resolved from the employee list, so the role list shows instead of the not found page + await waitFor(() => { + expect(screen.getByTestId('WorkspaceMemberDetailsRolePage')).toBeOnTheScreen(); + }); + expect(screen.getByText('Workspace admin')).toBeOnTheScreen(); + expect(screen.queryByTestId('NotFoundPage')).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should update the role with the real accountID when the route holds a stale optimistic accountID', async () => { + // Given a Role page whose route still holds the optimistic accountID of a member whose invite has completed + const staleOptimisticAccountID = generateAccountID(invitedEmail); + const {unmount} = renderPage({policyID: policy.id, accountID: String(staleOptimisticAccountID)}); + await waitForBatchedUpdatesWithAct(); + + // When the admin picks a new role for the member + await waitFor(() => { + expect(screen.getByText('Workspace admin')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByText('Workspace admin')); + await waitForBatchedUpdatesWithAct(); + + // Then the update targets the member's real accountID, not the stale optimistic one, so the backend receives a valid member + expect(updateWorkspaceMembersRoleMock).toHaveBeenCalledWith(expect.objectContaining({id: policy.id}), [invitedEmail], [invitedAccountID], CONST.POLICY.ROLE.ADMIN); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); +}); From da2f0de28b2f423629b091f5b4b0f35b60e64458 Mon Sep 17 00:00:00 2001 From: "Maruf Sharifi (via MelvinBot)" Date: Thu, 15 Oct 2026 07:09:06 +0000 Subject: [PATCH 2/6] Go back to member details with the route accountID so a stale optimistic ID still pops to the screen below Co-authored-by: Maruf Sharifi --- .../workspace/members/WorkArrangementPage.tsx | 3 ++- .../WorkspaceMemberCustomFieldPage.tsx | 8 +++---- .../WorkspaceMemberDetailsRolePage.tsx | 8 ++++--- .../ui/WorkspaceMemberDetailsRolePageTest.tsx | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/pages/workspace/members/WorkArrangementPage.tsx b/src/pages/workspace/members/WorkArrangementPage.tsx index b2c6080f3192..8b9f54927d1f 100644 --- a/src/pages/workspace/members/WorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkArrangementPage.tsx @@ -68,7 +68,8 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa const currentIsOffice = getEffectiveWorkArrangement(member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); const navigateBackToDetails = () => { - Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + // Go back with the route's accountID so it matches the details screen below, which keeps the optimistic accountID it was opened with. + Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, routeAccountID)); }; const changeWorkArrangement = ({value}: WorkArrangementOption) => { diff --git a/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx b/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx index c62b7c7f40b0..c9895807f93d 100644 --- a/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx @@ -7,7 +7,6 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; -import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateMemberCustomField} from '@libs/actions/Policy/Policy'; @@ -44,15 +43,14 @@ function WorkspaceMemberCustomFieldPage({policy, route, personalDetails}: Worksp const {inputCallbackRef} = useAutoFocusInput(); const params = route.params; const customFieldType = params.customFieldType; - const routeAccountID = Number(params.accountID); - const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); - const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); - const accountID = memberPersonalDetails?.accountID ?? routeAccountID; + const accountID = Number(params.accountID); + const memberLogin = personalDetails?.[accountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, accountID); const member = policy?.employeeList?.[memberLogin]; const customFieldKey = CONST.CUSTOM_FIELD_KEYS[customFieldType]; const [customField, setCustomField] = useState(member?.[customFieldKey ?? ''] ?? ''); const customFieldText = translate(`workspace.common.${customFieldType}`); const policyID = params.policyID; + // Go back with the route's accountID, even if it is a stale optimistic one, so it matches the details screen below that was opened with it. const goBack = useCallback(() => { Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }, [accountID, policyID]); diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index b994bac23d96..c626a3fe250a 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -46,9 +46,11 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (for example Admin or Payments Admin). const isReimburser = isPolicyReimburser(policy, memberLogin); const allowedRoles = getAllowedRolesForMember(policy, memberLogin); + // Go back with the route's accountID so it matches the details screen below, which keeps the optimistic accountID it was opened with. + const memberDetailsRoute = ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, routeAccountID); useRedirectSubmitWorkspaceFeatureUpgrade({ policy, - backTo: ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), + backTo: memberDetailsRoute, upgradeFeatureAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, }); @@ -68,7 +70,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp return; } updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value); - Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + Navigation.goBack(memberDetailsRoute); }; return ( @@ -87,7 +89,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp policy={policy} onSelectRole={changeRole} allowedRoles={allowedRoles} - navigateBackTo={ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)} + navigateBackTo={memberDetailsRoute} /> diff --git a/tests/ui/WorkspaceMemberDetailsRolePageTest.tsx b/tests/ui/WorkspaceMemberDetailsRolePageTest.tsx index b9a90fef8d22..69665e13d9f1 100644 --- a/tests/ui/WorkspaceMemberDetailsRolePageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsRolePageTest.tsx @@ -22,6 +22,7 @@ import WorkspaceMemberDetailsRolePage from '@pages/workspace/members/WorkspaceMe import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {PortalProvider} from '@gorhom/portal'; @@ -169,4 +170,25 @@ describe('WorkspaceMemberDetailsRolePage', () => { unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should go back to the details route that holds the stale optimistic accountID', async () => { + // Given a Role page opened from a details screen whose route still holds the member's optimistic accountID + const staleOptimisticAccountID = generateAccountID(invitedEmail); + const {unmount} = renderPage({policyID: policy.id, accountID: String(staleOptimisticAccountID)}); + await waitForBatchedUpdatesWithAct(); + + // When the admin picks a new role for the member + await waitFor(() => { + expect(screen.getByText('Workspace admin')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByText('Workspace admin')); + await waitForBatchedUpdatesWithAct(); + + // Then going back targets the details route with the stale accountID, so it pops to the details screen below instead of + // replacing the Role page with a second details screen for the real accountID + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policy.id, staleOptimisticAccountID)); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); }); From 3fbc4f50d197ededa273408a4ccbc2b2b622dfba Mon Sep 17 00:00:00 2001 From: "Maruf Sharifi (via MelvinBot)" Date: Fri, 16 Oct 2026 12:13:23 +0000 Subject: [PATCH 3/6] Open member sub-pages with the details route accountID so Back returns to the same details screen after the invite resolves Co-authored-by: Maruf Sharifi --- .../members/WorkspaceMemberDetailsPage.tsx | 13 +++++++---- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index da48f19caf51..01b0644b311e 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -123,6 +123,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); const accountID = memberPersonalDetails?.accountID ?? routeAccountID; + // Open the member's sub-pages with the route's accountID, even if it is a stale optimistic one, so they go back to this + // screen instead of stacking a second details screen for the real accountID. The sub-pages resolve the real accountID themselves. + const memberDetailsRoute = ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, routeAccountID); const member = policy?.employeeList?.[memberLogin]; const memberWorkArrangement = getEffectiveWorkArrangement(member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); const prevMember = usePrevious(member); @@ -398,12 +401,12 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, - ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), + memberDetailsRoute, ) ) { return; } - Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); + Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, routeAccountID)); }} /> {policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && isWorkArrangementBetaEnabled && ( @@ -416,7 +419,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM shouldGreyOutWhenDisabled={false} shouldUseDefaultCursorWhenDisabled pressableTestID="member-work-arrangement-menu-item" - onPress={() => Navigation.navigate(ROUTES.WORKSPACE_MEMBER_WORK_ARRANGEMENT.getRoute(policyID, accountID))} + onPress={() => Navigation.navigate(ROUTES.WORKSPACE_MEMBER_WORK_ARRANGEMENT.getRoute(policyID, routeAccountID))} /> )} {isControlPolicy(policy) && ( @@ -424,7 +427,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, accountID, 'customField1')) : undefined} + onPress={canWriteMembers ? () => Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, routeAccountID, 'customField1')) : undefined} testID="member-customField1-menu-item" value={member.employeeUserID} /> @@ -432,7 +435,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, accountID, 'customField2')) : undefined} + onPress={canWriteMembers ? () => Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, routeAccountID, 'customField2')) : undefined} testID="member-customField2-menu-item" value={member.employeePayrollID} /> diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index a0f7cf1bce9d..2808b513b8b0 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -12,6 +12,7 @@ import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import {generateAccountID} from '@libs/UserUtils'; @@ -21,6 +22,7 @@ import WorkspaceMemberDetailsPage from '@pages/workspace/members/WorkspaceMember import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Policy} from '@src/types/onyx'; @@ -269,6 +271,27 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + it('should open the Role page with the stale optimistic accountID from its own route', async () => { + // Given a details page whose route still holds the member's optimistic accountID after the invite swapped in the real one + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => {}); + const staleOptimisticAccountID = generateAccountID(invitedEmail); + const {unmount} = renderPage({policyID: policy.id, accountID: String(staleOptimisticAccountID)}); + await waitForBatchedUpdatesWithAct(); + + // When the admin opens the member's Role page + const roleItem = await screen.findByTestId('member-role-menu-item'); + fireEvent.press(roleItem, {nativeEvent: {}, type: 'press', target: roleItem, currentTarget: roleItem}); + await waitForBatchedUpdatesWithAct(); + + // Then the Role route keeps the stale accountID, so going back from it pops to this details screen instead of + // stacking a second details screen for the real accountID + expect(navigateSpy).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policy.id, staleOptimisticAccountID)); + expect(navigateSpy).not.toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policy.id, invitedAccountID)); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should keep showing the member details when the stale optimistic accountID was derived without the SMS domain', async () => { const staleOptimisticAccountID = generateAccountID(phoneNumber); const {unmount} = renderPage({policyID: policy.id, accountID: String(staleOptimisticAccountID)}); From 35b9afd4a968b76ba61e9cf0cb21280c2ed0b3d6 Mon Sep 17 00:00:00 2001 From: "Maruf Sharifi (via MelvinBot)" Date: Fri, 16 Oct 2026 12:20:33 +0000 Subject: [PATCH 4/6] Fix: apply Oxfmt formatting to WorkspaceMemberDetailsPage Co-authored-by: Maruf Sharifi --- .../members/WorkspaceMemberDetailsPage.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 01b0644b311e..f7f7c5037d76 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -396,14 +396,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM shouldUseDefaultCursorWhenDisabled pressableTestID="member-role-menu-item" onPress={() => { - if ( - tryNavigateToSubmitWorkspaceUpgrade( - policy, - true, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, - memberDetailsRoute, - ) - ) { + if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, memberDetailsRoute)) { return; } Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, routeAccountID)); @@ -427,7 +420,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, routeAccountID, 'customField1')) : undefined} + onPress={ + canWriteMembers ? () => Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, routeAccountID, 'customField1')) : undefined + } testID="member-customField1-menu-item" value={member.employeeUserID} /> @@ -435,7 +430,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, routeAccountID, 'customField2')) : undefined} + onPress={ + canWriteMembers ? () => Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, routeAccountID, 'customField2')) : undefined + } testID="member-customField2-menu-item" value={member.employeePayrollID} /> From 5f0aaba815d4338bf874933ce3c5936c59f44262 Mon Sep 17 00:00:00 2001 From: "Maruf Sharifi (via MelvinBot)" Date: Mon, 19 Oct 2026 19:24:53 +0000 Subject: [PATCH 5/6] Extract workspace member route resolution into useWorkspaceMemberFromRoute Co-authored-by: Maruf Sharifi --- src/hooks/useWorkspaceMemberFromRoute.ts | 33 +++++++++++++++++++ .../workspace/members/WorkArrangementPage.tsx | 8 ++--- .../members/WorkspaceMemberDetailsPage.tsx | 7 ++-- .../WorkspaceMemberDetailsRolePage.tsx | 8 ++--- 4 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 src/hooks/useWorkspaceMemberFromRoute.ts diff --git a/src/hooks/useWorkspaceMemberFromRoute.ts b/src/hooks/useWorkspaceMemberFromRoute.ts new file mode 100644 index 000000000000..fd2453529cbf --- /dev/null +++ b/src/hooks/useWorkspaceMemberFromRoute.ts @@ -0,0 +1,33 @@ +import {getMemberLoginByOptimisticAccountID} from '@libs/PolicyUtils'; + +import type {PersonalDetails, PersonalDetailsList, Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import usePersonalDetailByLogin from './usePersonalDetailByLogin'; + +type WorkspaceMemberFromRoute = { + /** The member's login, empty when the member can't be resolved */ + memberLogin: string; + + /** The member's personal details, looked up by login so they survive the optimistic accountID swap */ + memberPersonalDetails: PersonalDetails | undefined; + + /** The member's current accountID, which differs from the route's once the invite request finishes */ + accountID: number; +}; + +/** + * Resolves the workspace member a member page was opened for. The route may hold the optimistic accountID of a member + * who was just invited, which stops matching personal details once the backend supplies the real one, so the member + * is resolved by login instead. + */ +function useWorkspaceMemberFromRoute(policy: OnyxEntry, personalDetails: OnyxEntry, routeAccountID: number): WorkspaceMemberFromRoute { + const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); + const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); + const accountID = memberPersonalDetails?.accountID ?? routeAccountID; + + return {memberLogin, memberPersonalDetails, accountID}; +} + +export default useWorkspaceMemberFromRoute; diff --git a/src/pages/workspace/members/WorkArrangementPage.tsx b/src/pages/workspace/members/WorkArrangementPage.tsx index 8b9f54927d1f..0d6d1cf75bc6 100644 --- a/src/pages/workspace/members/WorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkArrangementPage.tsx @@ -8,14 +8,14 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; -import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceMemberFromRoute from '@hooks/useWorkspaceMemberFromRoute'; import {setEmployeeWorkArrangement} from '@libs/actions/Policy/DistanceRate'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberWrite, getMemberLoginByOptimisticAccountID, isMemberInHomeAndOfficeWorkspace} from '@libs/PolicyUtils'; +import {canMemberWrite, isMemberInHomeAndOfficeWorkspace} from '@libs/PolicyUtils'; import {getEffectiveWorkArrangement, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -57,9 +57,7 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa const {isBetaEnabled} = usePermissions(); const isWorkArrangementBetaEnabled = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS); - const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); - const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); - const accountID = memberPersonalDetails?.accountID ?? routeAccountID; + const {memberLogin, accountID} = useWorkspaceMemberFromRoute(policy, personalDetails, routeAccountID); const member = policy?.employeeList?.[memberLogin]; const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); const canAccessWorkArrangementPage = canWriteMembers && isWorkArrangementBetaEnabled && isMemberInHomeAndOfficeWorkspace(policy, memberLogin); diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index f7f7c5037d76..76e28a1a6e47 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -23,13 +23,13 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import usePrevious from '@hooks/usePrevious'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceMemberFromRoute from '@hooks/useWorkspaceMemberFromRoute'; import {setPolicyPreventSelfApproval} from '@libs/actions/Policy/Policy'; import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; @@ -44,7 +44,6 @@ import { canMemberAssignRole, canMemberManageMemberWithRole, canMemberWrite, - getMemberLoginByOptimisticAccountID, hasActiveExpensifyCard, isControlPolicy, isPolicyApprover, @@ -120,9 +119,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const showRuleBotGuardModal = useRuleBotGuardModal(); const routeAccountID = Number(route.params.accountID); - const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); - const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); - const accountID = memberPersonalDetails?.accountID ?? routeAccountID; + const {memberLogin, memberPersonalDetails, accountID} = useWorkspaceMemberFromRoute(policy, personalDetails, routeAccountID); // Open the member's sub-pages with the route's accountID, even if it is a stale optimistic one, so they go back to this // screen instead of stacking a second details screen for the real accountID. The sub-pages resolve the real accountID themselves. const memberDetailsRoute = ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, routeAccountID); diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index c626a3fe250a..a5626890818b 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -3,9 +3,9 @@ import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; +import useWorkspaceMemberFromRoute from '@hooks/useWorkspaceMemberFromRoute'; import {updateWorkspaceMembersRole} from '@libs/actions/Policy/Member'; import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; @@ -13,7 +13,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getAllowedRolesForMember, isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; -import {canMemberAssignRole, canRolePay, getMemberLoginByOptimisticAccountID} from '@libs/PolicyUtils'; +import {canMemberAssignRole, canRolePay} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -38,9 +38,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const policyID = route.params.policyID; const showRuleBotGuardModal = useRuleBotGuardModal(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); - const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); - const accountID = memberPersonalDetails?.accountID ?? routeAccountID; + const {memberLogin, accountID} = useWorkspaceMemberFromRoute(policy, personalDetails, routeAccountID); const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (for example Admin or Payments Admin). From 20e2616b22fa129321c9ce33d68f853ffcd96332 Mon Sep 17 00:00:00 2001 From: "Maruf Sharifi (via MelvinBot)" Date: Wed, 21 Oct 2026 04:05:17 +0000 Subject: [PATCH 6/6] Reuse getMemberLoginByAccountID, resolve the Custom field member with useWorkspaceMemberFromRoute, and cover stale optimistic accountIDs in tests Remove the duplicate getMemberLoginByOptimisticAccountID in favor of the existing getMemberLoginByAccountID. Update the WorkArrangementPage back-navigation test to expect the stale optimistic accountID, and add WorkspaceMemberCustomFieldPage tests for a stale optimistic accountID. Co-authored-by: Maruf Sharifi --- src/hooks/useWorkspaceMemberFromRoute.ts | 4 +- src/libs/PolicyUtils/members.ts | 18 -- .../WorkspaceMemberCustomFieldPage.tsx | 4 +- tests/ui/WorkArrangementPageTest.tsx | 7 +- .../ui/WorkspaceMemberCustomFieldPageTest.tsx | 175 ++++++++++++++++++ 5 files changed, 183 insertions(+), 25 deletions(-) create mode 100644 tests/ui/WorkspaceMemberCustomFieldPageTest.tsx diff --git a/src/hooks/useWorkspaceMemberFromRoute.ts b/src/hooks/useWorkspaceMemberFromRoute.ts index fd2453529cbf..1b2e673480bd 100644 --- a/src/hooks/useWorkspaceMemberFromRoute.ts +++ b/src/hooks/useWorkspaceMemberFromRoute.ts @@ -1,4 +1,4 @@ -import {getMemberLoginByOptimisticAccountID} from '@libs/PolicyUtils'; +import {getMemberLoginByAccountID} from '@libs/WorkArrangementUtils'; import type {PersonalDetails, PersonalDetailsList, Policy} from '@src/types/onyx'; @@ -23,7 +23,7 @@ type WorkspaceMemberFromRoute = { * is resolved by login instead. */ function useWorkspaceMemberFromRoute(policy: OnyxEntry, personalDetails: OnyxEntry, routeAccountID: number): WorkspaceMemberFromRoute { - const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); + const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByAccountID(policy, routeAccountID); const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); const accountID = memberPersonalDetails?.accountID ?? routeAccountID; diff --git a/src/libs/PolicyUtils/members.ts b/src/libs/PolicyUtils/members.ts index e948b28371fe..9735c5395cba 100644 --- a/src/libs/PolicyUtils/members.ts +++ b/src/libs/PolicyUtils/members.ts @@ -215,23 +215,6 @@ function getAccountIDForSubmitManagerEmail( return undefined; } -/** - * A member who was just invited is listed under an optimistic accountID derived from their login, and that entry is removed from - * personal details once the invite request finishes and the backend supplies the real accountID. A member page opened - * while the invite was still in flight therefore holds a stale accountID in its route. Recover the login from the employee list - * so the page keeps resolving the member across that swap. - */ -function getMemberLoginByOptimisticAccountID(policy: OnyxEntry, accountID: number) { - const matchesOptimisticAccountID = (login: string) => generateAccountID(login) === accountID || generateAccountID(Str.removeSMSDomain(login)) === accountID; - // A member invited by one of their secondary logins ends up listed under their primary login, so a route ID derived - // from the secondary matches no employee — primaryLoginsInvited records that mapping and recovers the member. - const primaryLoginOfInvitedSecondary = Object.entries(policy?.primaryLoginsInvited ?? {}).find(([secondaryLogin]) => matchesOptimisticAccountID(secondaryLogin))?.[1]; - if (primaryLoginOfInvitedSecondary) { - return primaryLoginOfInvitedSecondary; - } - return Object.keys(policy?.employeeList ?? {}).find(matchesOptimisticAccountID) ?? ''; -} - /** * Get login list that we should not show in the workspace invite options */ @@ -408,7 +391,6 @@ export { getIneligibleInvitees, getExcludedUsers, getMemberAccountIDsForWorkspace, - getMemberLoginByOptimisticAccountID, getAccountIDForSubmitManagerEmail, getGuideAndAccountManagerInfo, getSoftExclusionsForGuideAndAccountManager, diff --git a/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx b/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx index c9895807f93d..b4ec8c63e488 100644 --- a/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx @@ -8,12 +8,12 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceMemberFromRoute from '@hooks/useWorkspaceMemberFromRoute'; import {updateMemberCustomField} from '@libs/actions/Policy/Policy'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getMemberLoginByOptimisticAccountID} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -44,7 +44,7 @@ function WorkspaceMemberCustomFieldPage({policy, route, personalDetails}: Worksp const params = route.params; const customFieldType = params.customFieldType; const accountID = Number(params.accountID); - const memberLogin = personalDetails?.[accountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, accountID); + const {memberLogin} = useWorkspaceMemberFromRoute(policy, personalDetails, accountID); const member = policy?.employeeList?.[memberLogin]; const customFieldKey = CONST.CUSTOM_FIELD_KEYS[customFieldType]; const [customField, setCustomField] = useState(member?.[customFieldKey ?? ''] ?? ''); diff --git a/tests/ui/WorkArrangementPageTest.tsx b/tests/ui/WorkArrangementPageTest.tsx index 2c2a098b1be6..e1e08cec9525 100644 --- a/tests/ui/WorkArrangementPageTest.tsx +++ b/tests/ui/WorkArrangementPageTest.tsx @@ -287,7 +287,7 @@ describe('WorkArrangementPage', () => { expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(syncedPolicy, [syncedAccountID], true, syncedPersonalDetails, expect.any(Function)); }); - it('keeps the member profile as the back destination after an offline invite syncs', async () => { + it('goes back to the details route that holds the optimistic account ID after an offline invite syncs', async () => { // Given the arrangement page was opened while the invite had only an optimistic account ID const optimisticAccountID = generateAccountID(memberLogin); const optimisticPersonalDetails: PersonalDetailsList = { @@ -320,7 +320,8 @@ describe('WorkArrangementPage', () => { headerProps?.onPress?.(); }); - // Then navigation targets the member profile with the synced account ID - expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, syncedAccountID)); + // Then navigation targets the details route with the optimistic account ID, so it pops to the details screen below that was + // opened with it instead of replacing this page with a second details screen for the synced account ID + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, optimisticAccountID)); }); }); diff --git a/tests/ui/WorkspaceMemberCustomFieldPageTest.tsx b/tests/ui/WorkspaceMemberCustomFieldPageTest.tsx new file mode 100644 index 000000000000..929d788e9719 --- /dev/null +++ b/tests/ui/WorkspaceMemberCustomFieldPageTest.tsx @@ -0,0 +1,175 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import {ModalProvider} from '@components/Modal/Global/ModalContext'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; + +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; + +import {updateMemberCustomField} from '@libs/actions/Policy/Policy'; +import Navigation from '@libs/Navigation/Navigation'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import {generateAccountID} from '@libs/UserUtils'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import WorkspaceMemberCustomFieldPage from '@pages/workspace/members/WorkspaceMemberCustomFieldPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/actions/Policy/Policy', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/actions/Policy/Policy'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + updateMemberCustomField: jest.fn(), + }; +}); + +TestHelper.setupGlobalFetchMock(); + +const updateMemberCustomFieldMock = jest.mocked(updateMemberCustomField); + +const Stack = createPlatformStackNavigator(); + +const renderPage = (initialParams: SettingsNavigatorParamList[typeof SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD]) => { + return render( + + + + + + + + + , + ); +}; + +describe('WorkspaceMemberCustomFieldPage', () => { + const ownerAccountID = 1; + const ownerEmail = 'owner@example.com'; + const selfAccountID = 1206; + const selfEmail = 'self@example.com'; + const invitedAccountID = 5555; + const invitedEmail = 'newmember@example.com'; + const invitedEmployeeUserID = 'EMP-42'; + + const policy = { + ...LHNTestUtils.getFakePolicy(), + role: CONST.POLICY.ROLE.ADMIN, + owner: ownerEmail, + ownerAccountID, + type: CONST.POLICY.TYPE.CORPORATE, + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN}, + [selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.ADMIN}, + [invitedEmail]: {email: invitedEmail, role: CONST.POLICY.ROLE.USER, employeeUserID: invitedEmployeeUserID}, + }, + }; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(async () => { + await TestHelper.signInWithTestUser(selfAccountID, selfEmail, undefined, 'Self'); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(`${ONYXKEYS.PERSONAL_DETAILS_LIST}`, { + [ownerAccountID]: TestHelper.buildPersonalDetails(ownerEmail, ownerAccountID, 'Owner'), + [selfAccountID]: TestHelper.buildPersonalDetails(selfEmail, selfAccountID, 'Self'), + [invitedAccountID]: TestHelper.buildPersonalDetails(invitedEmail, invitedAccountID, 'Invited'), + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + }); + const responsiveLayout: ResponsiveLayoutResult = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isInLandscapeMode: false, + }; + jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue(responsiveLayout); + jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should show the member custom field value when the route holds a stale optimistic accountID', async () => { + // Given a custom field page opened while the invite was in flight, so its route accountID is the login-derived + // optimistic one, which no longer has a personal details entry after the backend swaps in the real accountID + const staleOptimisticAccountID = generateAccountID(invitedEmail); + + // When the page renders + const {unmount} = renderPage({policyID: policy.id, accountID: String(staleOptimisticAccountID), customFieldType: 'customField1'}); + await waitForBatchedUpdatesWithAct(); + + // Then the member is still resolved from the employee list, so their saved value prefills the input + await waitFor(() => { + expect(screen.getByDisplayValue(invitedEmployeeUserID)).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should save for the member and go back to the details route that holds the stale optimistic accountID', async () => { + // Given a custom field page whose route still holds the optimistic accountID of a member whose invite has completed + const staleOptimisticAccountID = generateAccountID(invitedEmail); + const {unmount} = renderPage({policyID: policy.id, accountID: String(staleOptimisticAccountID), customFieldType: 'customField1'}); + await waitForBatchedUpdatesWithAct(); + + // When the admin edits the value and saves it + await waitFor(() => { + expect(screen.getByDisplayValue(invitedEmployeeUserID)).toBeOnTheScreen(); + }); + fireEvent.changeText(screen.getByDisplayValue(invitedEmployeeUserID), 'EMP-43'); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then the update targets the member's login, and going back targets the details route with the stale accountID so it + // pops to the details screen below instead of stacking a second details screen for the real accountID + expect(updateMemberCustomFieldMock).toHaveBeenCalledWith(policy.id, invitedEmail, 'customField1', 'EMP-43', invitedEmployeeUserID); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policy.id, staleOptimisticAccountID)); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); +});