Skip to content
8 changes: 8 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,14 @@ const DYNAMIC_ROUTES = {
path: 'owner-change-failure',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK, SCREENS.WORKSPACE.MEMBER_DETAILS],
},
WORKSPACE_HR_SYNC_RESULTS: {
// `policyID` is not repeated here: both entry screens are already workspace-scoped, so the
// suffix inherits their `:policyID` (same as WORKSPACE_INVITE above).
path: 'hr-sync-results',
// The results screen opens automatically when an HR sync finishes, and a sync can complete
// while the user is on either the HR page or the members list, so both are entry screens.
entryScreens: [SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.MEMBERS],
},
WORKSPACE_OWNER_CHANGE_CHECK: {
path: 'change-owner/:policyID/:accountID/:error',
entryScreens: [SCREENS.WORKSPACE.MEMBER_DETAILS, SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACES_LIST],
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,7 @@ const SCREENS = {
INITIAL: 'Workspace_Initial',
PROFILE: 'Workspace_Overview',
HR: 'Workspace_HR',
DYNAMIC_HR_SYNC_RESULTS: 'Dynamic_HR_Sync_Results',
HR_GUSTO_APPROVAL_MODE: 'Workspace_HR_Gusto_Approval_Mode',
HR_GUSTO_FINAL_APPROVER: 'Workspace_HR_Gusto_Final_Approver',
HR_ZENEFITS_APPROVAL_MODE: 'Workspace_HR_Zenefits_Approval_Mode',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import HRSyncResultsModal from '@components/HRSyncResultsModal';
import {useModal} from '@components/Modal/Global/ModalContext';

import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import TransitionTracker from '@libs/Navigation/TransitionTracker';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import type {PolicyConnectionSyncProgress} from '@src/types/onyx/Policy';

import type {OnyxEntry} from 'react-native-onyx';
Expand All @@ -16,26 +16,23 @@ import useOnyx from './useOnyx';
import usePrevious from './usePrevious';

/**
* Watches an HR provider's sync progress and automatically opens the `HRSyncResultsModal`
* Watches an HR provider's sync progress and automatically opens the HR sync results screen
* when the sync transitions to the `JOB_DONE` stage with a result payload.
*/
function useHRSyncResultsModal(policyID: string, connectionSyncProgress: OnyxEntry<PolicyConnectionSyncProgress>, isFocused: boolean) {
const modal = useModal();
function useHRSyncResultsPage(connectionSyncProgress: OnyxEntry<PolicyConnectionSyncProgress>, isFocused: boolean) {
const previousSyncProgress = usePrevious(connectionSyncProgress);
const pendingSyncResultRef = useRef<Pick<PolicyConnectionSyncProgress, 'connectionName' | 'result'> | null>(null);
const [isAnyModalActive] = useOnyx(ONYXKEYS.MODAL, {selector: isModalActiveSelector});

const connectionName = connectionSyncProgress?.connectionName;
const showSyncResultsModal = useEffectEvent((syncResult: PolicyConnectionSyncProgress['result'], syncConnectionName: PolicyConnectionSyncProgress['connectionName']) => {
const openSyncResultsScreen = useEffectEvent((syncResult: PolicyConnectionSyncProgress['result'], syncConnectionName: PolicyConnectionSyncProgress['connectionName']) => {
if (!syncResult || !syncConnectionName) {
return;
}

modal.showModal({
component: HRSyncResultsModal,
props: {result: syncResult, policyID},
id: `${syncConnectionName}-sync-results-${policyID}`,
});
// The result payload stays in Onyx; the screen re-reads it from the `policyID` it inherits
// from the workspace route, so nothing rich has to be serialized into navigation params.
Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_HR_SYNC_RESULTS.path));
});

useEffect(() => {
Expand All @@ -56,7 +53,7 @@ function useHRSyncResultsModal(policyID: string, connectionSyncProgress: OnyxEnt

const handle = TransitionTracker.runAfterTransitions({
callback: () => {
showSyncResultsModal(pendingSyncResult.result, pendingSyncResult.connectionName);
openSyncResultsScreen(pendingSyncResult.result, pendingSyncResult.connectionName);
pendingSyncResultRef.current = null;
},
waitForUpcomingTransition: true,
Expand All @@ -74,4 +71,4 @@ function useHRSyncResultsModal(policyID: string, connectionSyncProgress: OnyxEnt
]);
}

export default useHRSyncResultsModal;
export default useHRSyncResultsPage;
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,7 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
[SCREENS.SETTINGS.LOCK.LOCK_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/settings/Security/LockAccount/LockAccountPage').default,
[SCREENS.SETTINGS.LOCK.UNLOCK_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/settings/Security/LockAccount/UnlockAccountPage').default,
[SCREENS.SETTINGS.LOCK.FAILED_TO_LOCK_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/settings/Security/LockAccount/FailedToLockAccountPage').default,
[SCREENS.WORKSPACE.DYNAMIC_HR_SYNC_RESULTS]: () => require<ReactComponentModule>('../../../../pages/workspace/hr/DynamicHRSyncResultsPage').default,
[SCREENS.WORKSPACE.HR_GUSTO_APPROVAL_MODE]: () => require<ReactComponentModule>('../../../../pages/workspace/hr/gusto/GustoApprovalModePage').default,
[SCREENS.WORKSPACE.HR_GUSTO_FINAL_APPROVER]: () => require<ReactComponentModule>('../../../../pages/workspace/hr/gusto/GustoFinalApproverPage').default,
[SCREENS.WORKSPACE.HR_ZENEFITS_APPROVAL_MODE]: () => require<ReactComponentModule>('../../../../pages/workspace/hr/zenefits/ZenefitsApprovalModePage').default,
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,7 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
[SCREENS.WORKSPACE.RULES_REIMBURSABLE_DEFAULT]: {
path: ROUTES.RULES_REIMBURSABLE_DEFAULT.route,
},
[SCREENS.WORKSPACE.DYNAMIC_HR_SYNC_RESULTS]: DYNAMIC_ROUTES.WORKSPACE_HR_SYNC_RESULTS.path,
[SCREENS.WORKSPACE.HR_GUSTO_APPROVAL_MODE]: {
path: ROUTES.WORKSPACE_HR_GUSTO_APPROVAL_MODE.route,
},
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,9 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.RULES_REIMBURSABLE_DEFAULT]: {
policyID: string;
};
[SCREENS.WORKSPACE.DYNAMIC_HR_SYNC_RESULTS]: {
policyID: string;
};
[SCREENS.WORKSPACE.HR_GUSTO_APPROVAL_MODE]: {
policyID: string;
};
Expand Down
4 changes: 2 additions & 2 deletions src/pages/workspace/WorkspaceMembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import TextLink from '@components/TextLink';

import useConfirmModal from '@hooks/useConfirmModal';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useHRSyncResultsModal from '@hooks/useHRSyncResultsModal';
import useHRSyncResultsPage from '@hooks/useHRSyncResultsPage';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
Expand Down Expand Up @@ -455,7 +455,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
}
}, [invitedEmailsToAccountIDsDraft, isFocused, accountIDs, prevAccountIDs, invitedEmails, policyID]);

useHRSyncResultsModal(policyID, connectionSyncProgress, isFocused);
useHRSyncResultsPage(connectionSyncProgress, isFocused);

const headerMessage = useMemo(() => {
if (isOfflineAndNoMemberDataAvailable) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,54 +1,67 @@
/**
* Shows the outcome of a finished HR provider sync (employees added, removed and skipped).
*
* The sync payload is read from Onyx (the policy's connection sync progress), so only the workspace's
* `policyID` needs to travel through the route.
*/
import Button from '@components/ButtonComposed';
import FixedFooter from '@components/FixedFooter';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Icon from '@components/Icon';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';

import useDynamicBackPath from '@hooks/useDynamicBackPath';
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import type HrSyncResult from '@libs/API/HrSyncResult';
import {getConnectedHRProvider} from '@libs/HRUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';

import type {SettingsNavigatorParamList} from '@navigation/types';

import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';

import React, {useState} from 'react';
import {View} from 'react-native';

import type {ModalProps} from './Modal/Global/ModalContext';

import Button from './ButtonComposed';
import FixedFooter from './FixedFooter';
import HeaderWithBackButton from './HeaderWithBackButton';
import Icon from './Icon';
import Modal from './Modal';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import ScrollView from './ScrollView';
import Text from './Text';

type HRSyncResultsModalProps = ModalProps & {
/** Sync result returned by the completed HR sync job */
result: HrSyncResult;
type DynamicHRSyncResultsPageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.DYNAMIC_HR_SYNC_RESULTS>;

/** ID of the policy associated with this sync */
policyID: string;
};

function HRSyncResultsModal({result, policyID, closeModal}: HRSyncResultsModalProps) {
function DynamicHRSyncResultsPage({route}: DynamicHRSyncResultsPageProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
const icons = useMemoizedLazyExpensifyIcons(['DownArrow']);
const illustrations = useMemoizedLazyIllustrations(['SyncUsers']);
const [isSkippedSectionExpanded, setIsSkippedSectionExpanded] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const backPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_HR_SYNC_RESULTS.path);

const policyID = route.params.policyID;
const [providerDisplayName = ''] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
selector: (policy) => getConnectedHRProvider(policy)?.displayName ?? '',
});
const addedCount = result.addedEmployeesCount ?? 0;
const removedCount = result.removedEmployeesCount ?? 0;
const skippedCount = result.skippedEmployees?.length ?? 0;
// The sync payload already lives in Onyx, so this screen only needs the policy ID in its route
// params — the result itself never has to travel through navigation state.
const [result] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`, {
Comment thread
mountiny marked this conversation as resolved.
selector: (connectionSyncProgress) => connectionSyncProgress?.result,
});

const addedCount = result?.addedEmployeesCount ?? 0;
const removedCount = result?.removedEmployeesCount ?? 0;
const skippedCount = result?.skippedEmployees?.length ?? 0;

const hideModal = () => setIsVisible(false);
const goBack = () => Navigation.goBack(backPath);

const renderResultSummary = (label: string, count: number) => (
<View style={[styles.mb6]}>
Expand All @@ -58,21 +71,21 @@ function HRSyncResultsModal({result, policyID, closeModal}: HRSyncResultsModalPr
);

return (
<Modal
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
isVisible={isVisible}
onClose={hideModal}
onModalHide={closeModal}
shouldHandleNavigationBack
enableEdgeToEdgeBottomSafeAreaPadding
// Deep-linkable, so it must gate on workspace HR access — otherwise a user without it could
// open this URL and read the skipped-employee list straight from Onyx.
<AccessOrNotFoundWrapper
accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]}
policyID={policyID}
featureName={CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED}
policyFeature={CONST.POLICY.POLICY_FEATURE.MORE_FEATURES}
>
<View
testID="HRSyncResultsModal"
style={[styles.flex1, styles.appBG]}
<ScreenWrapper
testID="DynamicHRSyncResultsPage"
enableEdgeToEdgeBottomSafeAreaPadding
>
<HeaderWithBackButton
title={translate('workspace.hr.syncResults.title', providerDisplayName)}
onBackButtonPress={hideModal}
onBackButtonPress={goBack}
/>
<ScrollView
contentContainerStyle={[styles.flexGrow1, styles.ph5, styles.pb8]}
Expand All @@ -90,7 +103,7 @@ function HRSyncResultsModal({result, policyID, closeModal}: HRSyncResultsModalPr
{renderResultSummary(translate('workspace.hr.syncResults.removed'), removedCount)}
<PressableWithoutFeedback
accessibilityLabel={translate('workspace.hr.syncResults.skipped')}
sentryLabel="HRSyncResultsModal-SkippedEmployees"
sentryLabel="DynamicHRSyncResultsPage-SkippedEmployees"
role={CONST.ROLE.BUTTON}
onPress={() => setIsSkippedSectionExpanded((isExpanded) => !isExpanded)}
style={[styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter]}
Expand All @@ -102,11 +115,11 @@ function HRSyncResultsModal({result, policyID, closeModal}: HRSyncResultsModalPr
<Icon
src={icons.DownArrow}
fill={theme.icon}
additionalStyles={isSkippedSectionExpanded ? {transform: [{rotate: '180deg'}]} : undefined}
additionalStyles={isSkippedSectionExpanded ? styles.flipUpsideDown : undefined}
/>
</PressableWithoutFeedback>
{isSkippedSectionExpanded &&
result.skippedEmployees?.map((employee) => (
result?.skippedEmployees?.map((employee) => (
<View
key={employee.id}
style={[styles.mt4]}
Expand All @@ -118,16 +131,16 @@ function HRSyncResultsModal({result, policyID, closeModal}: HRSyncResultsModalPr
</ScrollView>
<FixedFooter addBottomSafeAreaPadding>
<Button
size={CONST.BUTTON_SIZE.LARGE}
variant={CONST.BUTTON_VARIANT.SUCCESS}
onPress={hideModal}
size={CONST.BUTTON_SIZE.LARGE}
onPress={goBack}
>
<Button.Text>{translate('common.buttonConfirm')}</Button.Text>
</Button>
</FixedFooter>
</View>
</Modal>
</ScreenWrapper>
</AccessOrNotFoundWrapper>
);
}

export default HRSyncResultsModal;
export default DynamicHRSyncResultsPage;
4 changes: 2 additions & 2 deletions src/pages/workspace/hr/WorkspaceHRPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import CompactSearchBar from '@components/SearchBar/CompactSearchBar';
import Section from '@components/Section';

import useConfirmModal from '@hooks/useConfirmModal';
import useHRSyncResultsModal from '@hooks/useHRSyncResultsModal';
import useHRSyncResultsPage from '@hooks/useHRSyncResultsPage';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useMergeHRInitialSyncingModal from '@hooks/useMergeHRInitialSyncingModal';
Expand Down Expand Up @@ -70,7 +70,7 @@ function WorkspaceHRPage({
openPolicyHRPage(policyID);
}, [policyID]);

useHRSyncResultsModal(policyID, connectionSyncProgress, isFocused);
useHRSyncResultsPage(connectionSyncProgress, isFocused);
useMergeHRInitialSyncingModal(policyID, isFocused);

const cards = getHRCards({
Expand Down
Loading