diff --git a/src/hooks/useWorkspaceMemberFromRoute.ts b/src/hooks/useWorkspaceMemberFromRoute.ts new file mode 100644 index 000000000000..1b2e673480bd --- /dev/null +++ b/src/hooks/useWorkspaceMemberFromRoute.ts @@ -0,0 +1,33 @@ +import {getMemberLoginByAccountID} from '@libs/WorkArrangementUtils'; + +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 ?? getMemberLoginByAccountID(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 acabcfe78a56..19ba7bcf1a88 100644 --- a/src/pages/workspace/members/WorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkArrangementPage.tsx @@ -10,8 +10,8 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; 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 {setWorkspaceInviteWorkArrangementDraft} from '@libs/actions/Policy/Member'; @@ -19,7 +19,7 @@ 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 {getEffectiveWorkArrangement, getMemberLoginByAccountID, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; +import {getEffectiveWorkArrangement, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -67,11 +67,9 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa const [inviteWorkArrangementDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT}${policyID}`); // Only an existing member has an account ID here. The invite flow reads its own arrangement draft and never looks up a member. - const accountID = route.name === SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT ? Number(route.params.accountID) : CONST.DEFAULT_NUMBER_ID; - const memberLogin = personalDetails?.[accountID]?.login ?? getMemberLoginByAccountID(policy, accountID); + const routeAccountID = route.name === SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT ? Number(route.params.accountID) : CONST.DEFAULT_NUMBER_ID; + const {memberLogin, accountID} = useWorkspaceMemberFromRoute(policy, personalDetails, routeAccountID); const member = policy?.employeeList?.[memberLogin]; - const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); - const memberAccountID = memberPersonalDetails?.accountID ?? accountID; const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); const isHomeAndOfficeWorkspace = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; const canAccessWorkArrangementPage = @@ -85,7 +83,8 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa Navigation.goBack(inviteBackPath); return; } - Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, memberAccountID)); + // 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) => { @@ -97,7 +96,7 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa navigateBackToDetails(); return; } - setEmployeeWorkArrangement(policy, [memberAccountID], value, personalDetails); + setEmployeeWorkArrangement(policy, [accountID], value, personalDetails); navigateBackToDetails(); }; diff --git a/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx b/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx index a2e61958e358..873a7abdc1eb 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 {getMemberLoginByAccountID} from '@libs/WorkArrangementUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -44,12 +44,13 @@ function WorkspaceMemberCustomFieldPage({policy, route, personalDetails}: Worksp const params = route.params; const customFieldType = params.customFieldType; const accountID = Number(params.accountID); - const memberLogin = personalDetails?.[accountID]?.login ?? getMemberLoginByAccountID(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 ?? ''] ?? ''); 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/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 1ea6bc57d14b..3aa1309cce26 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -25,13 +25,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'; @@ -58,7 +58,7 @@ import { import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; -import {getEffectiveWorkArrangement, getMemberLoginByAccountID, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; +import {getEffectiveWorkArrangement, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import Navigation from '@navigation/Navigation'; @@ -125,9 +125,10 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const showRuleBotGuardModal = useRuleBotGuardModal(); const routeAccountID = Number(route.params.accountID); - const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByAccountID(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); const member = policy?.employeeList?.[memberLogin]; const memberWorkArrangement = getEffectiveWorkArrangement(member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); const memberOfficeLocation = getMemberOfficeLocation(getAssignableOfficeLocations(policy, translate, localeCompare), member?.officeID); @@ -407,17 +408,10 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM shouldUseDefaultCursorWhenDisabled pressableTestID="member-role-menu-item" onPress={() => { - if ( - tryNavigateToSubmitWorkspaceUpgrade( - policy, - true, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, - ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), - ) - ) { + if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, memberDetailsRoute)) { return; } - Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); + Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, routeAccountID)); }} /> {shouldShowOfficeLocation && ( @@ -442,7 +436,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) && ( @@ -450,7 +444,9 @@ 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} /> @@ -458,7 +454,9 @@ 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/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 9c9ae213425a..5e73ee8f6876 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -7,6 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; import useSingleExecution from '@hooks/useSingleExecution'; +import useWorkspaceMemberFromRoute from '@hooks/useWorkspaceMemberFromRoute'; import {updateWorkspaceMembersRole} from '@libs/actions/Policy/Member'; import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; @@ -36,22 +37,24 @@ type WorkspaceMemberDetailsRolePageProps = Omit>(); 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). 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, }); @@ -71,7 +74,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp return; } updateWorkspaceMembersRole(policy, [memberLogin], [accountID], selectedRole); - Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + Navigation.goBack(memberDetailsRoute); }; return ( @@ -90,7 +93,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp policy={policy} onSelectRole={(item: ListItemType) => setSelectedRole(item.value)} allowedRoles={allowedRoles} - navigateBackTo={ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)} + navigateBackTo={memberDetailsRoute} confirmButtonOptions={{ showButton: true, text: translate('common.save'), diff --git a/tests/ui/WorkArrangementPageTest.tsx b/tests/ui/WorkArrangementPageTest.tsx index 88dac1b21440..7fbc5ace6da8 100644 --- a/tests/ui/WorkArrangementPageTest.tsx +++ b/tests/ui/WorkArrangementPageTest.tsx @@ -287,7 +287,7 @@ describe('WorkArrangementPage', () => { expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(syncedPolicy, [syncedAccountID], true, syncedPersonalDetails); }); - 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(); + }); +}); diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index 21a372233dd2..23b9e8cf3d60 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'; import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; @@ -354,6 +356,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)}); diff --git a/tests/ui/WorkspaceMemberDetailsRolePageTest.tsx b/tests/ui/WorkspaceMemberDetailsRolePageTest.tsx new file mode 100644 index 000000000000..aa397bc496c7 --- /dev/null +++ b/tests/ui/WorkspaceMemberDetailsRolePageTest.tsx @@ -0,0 +1,198 @@ +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 {updateWorkspaceMembersRole} from '@libs/actions/Policy/Member'; +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 WorkspaceMemberDetailsRolePage from '@pages/workspace/members/WorkspaceMemberDetailsRolePage'; + +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/Member', () => { + // 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 and saves it + await waitFor(() => { + expect(screen.getByText('Workspace admin')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByText('Workspace admin')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.save'))); + 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(); + }); + + 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 and saves it + await waitFor(() => { + expect(screen.getByText('Workspace admin')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByText('Workspace admin')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.save'))); + 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(); + }); +});