Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/hooks/useWorkspaceMemberFromRoute.ts
Original file line number Diff line number Diff line change
@@ -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<Policy>, personalDetails: OnyxEntry<PersonalDetailsList>, 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;
15 changes: 7 additions & 8 deletions src/pages/workspace/members/WorkArrangementPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ 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';
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';
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file ships two behavior changes that no test exercises:

  • navigateBackToDetails now goes back with routeAccountID (the stale optimistic id) instead of memberAccountID (line 87).
  • setEmployeeWorkArrangement is now called with the resolved real accountID instead of memberAccountID (line 99).

Both mirror the Role-page fix, which has solid coverage in the new WorkspaceMemberDetailsRolePageTest. Please add an equivalent test for the Work arrangement flow under a stale optimistic accountID. It should assert that:

  • (a) setEmployeeWorkArrangement receives the real account id.
  • (b) goBack targets the details route built with the stale id, so it returns to the existing details screen instead of stacking a new one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 20e2616. main's WorkArrangementPageTest already covers both cases with an optimistic accountID that swaps to a synced one:

All 33 tests across the four member-page suites pass.

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 =
Expand All @@ -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) => {
Expand All @@ -97,7 +96,7 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa
navigateBackToDetails();
return;
}
setEmployeeWorkArrangement(policy, [memberAccountID], value, personalDetails, translate);
setEmployeeWorkArrangement(policy, [accountID], value, personalDetails, translate);
navigateBackToDetails();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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';
Expand Down Expand Up @@ -43,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 ?? '';
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]);
Expand Down
32 changes: 15 additions & 17 deletions src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -57,7 +57,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';
Expand Down Expand Up @@ -124,9 +124,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 prevMember = usePrevious(member);
Expand Down Expand Up @@ -403,17 +404,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));
}}
/>
{policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && isWorkArrangementBetaEnabled && (
Expand All @@ -426,23 +420,27 @@ 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) && (
<>
<OfflineWithFeedback pendingAction={member?.pendingFields?.employeeUserID}>
<MenuItemField
name={translate('workspace.common.customField1')}
onPress={canWriteMembers ? () => 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}
/>
</OfflineWithFeedback>
<OfflineWithFeedback pendingAction={member?.pendingFields?.employeePayrollID}>
<MenuItemField
name={translate('workspace.common.customField2')}
onPress={canWriteMembers ? () => 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}
/>
Expand Down
13 changes: 8 additions & 5 deletions src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {ListItemType} from '@components/WorkspaceMemberRoleList';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
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';
Expand Down Expand Up @@ -33,19 +34,21 @@ type WorkspaceMemberDetailsRolePageProps = Omit<WithPolicyAndFullscreenLoadingPr
};

function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: WorkspaceMemberDetailsRolePageProps) {
const accountID = Number(route.params.accountID);
const routeAccountID = Number(route.params.accountID);
const policyID = route.params.policyID;
const showRuleBotGuardModal = useRuleBotGuardModal();
const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-3 (docs)

This three-line member-resolution block — const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); followed by const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); and const accountID = memberPersonalDetails?.accountID ?? routeAccountID; — is now duplicated verbatim in WorkspaceMemberDetailsPage.tsx, WorkArrangementPage.tsx, and here. The PR already consolidated the getMemberLoginByOptimisticAccountID helper into PolicyUtils; this surrounding resolution logic should be consolidated the same way so a future change to how stale optimistic accountIDs are resolved only has to be made in one place.

Extract the block into a single shared hook in @hooks (e.g. useWorkspaceMemberFromRoute(policy, personalDetails, routeAccountID)) returning {memberLogin, accountID}, and call it from all three pages:

const {memberLogin, accountID} = useWorkspaceMemberFromRoute(policy, personalDetails, routeAccountID);

Reviewed at: 35b9afd | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f0aaba. I moved the block into a new hook, useWorkspaceMemberFromRoute. This page, WorkArrangementPage, and WorkspaceMemberDetailsPage now call the hook instead of repeating the three lines.


view run

const memberLogin = personalDetails?.[accountID]?.login ?? '';
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).
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,
});

Expand All @@ -65,7 +68,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 (
Expand All @@ -84,7 +87,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp
policy={policy}
onSelectRole={changeRole}
allowedRoles={allowedRoles}
navigateBackTo={ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)}
navigateBackTo={memberDetailsRoute}
/>
</ScreenWrapper>
</AccessOrNotFoundWrapper>
Expand Down
7 changes: 4 additions & 3 deletions tests/ui/WorkArrangementPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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));
});
});
Loading
Loading