Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
de5c54c
[HR Import] Sync now, initial sync modal
mhawryluk May 21, 2026
5e693c2
Generalize Members page to work with Merge HR like it does with Gusto
mhawryluk May 21, 2026
08076cf
Remove useMergeHRInitialSyncingModal from WorkspaceMembersPage
mhawryluk May 21, 2026
df31cef
Merge branch 'main' into merge-hr/sync
mhawryluk May 21, 2026
0d933d5
Add translations
mhawryluk May 21, 2026
86095ba
Adjust the initial syncType onxy data scheme
mhawryluk May 21, 2026
e573980
Merge branch 'main' into merge-hr/sync
mhawryluk May 21, 2026
ffbfb77
Update the onyx data structure for initial merge hr sync
mhawryluk May 22, 2026
1f148e6
Fix the description not showing up
mhawryluk May 22, 2026
897ea31
Remove MERGE_HR_SYNC_TITLE
mhawryluk May 22, 2026
2d31562
Don't show Other section too on initial sync
mhawryluk May 22, 2026
0a22e99
Add translations for 'syncing'
mhawryluk May 22, 2026
dbfd1fd
Implement review suggestions
mhawryluk May 22, 2026
7d16afb
Fix syncing status for Merge HR in Members page
mhawryluk May 22, 2026
b6e4c26
Refactor
mhawryluk May 22, 2026
158a688
Use TransitionTracker in useMergeHRInitialSyncingModal
mhawryluk May 22, 2026
38199e7
Add waitForUpcomingTransition prop to runAfterTransitions
mhawryluk May 22, 2026
50e428e
Show the modal only once the app is visible
mhawryluk May 22, 2026
bbb9810
Add justification comments
mhawryluk May 22, 2026
ab55e28
Fix initial sync modal not showing up on mobile
mhawryluk May 22, 2026
ce62021
Add temporary console.logs
mhawryluk May 22, 2026
581fea7
Remove console.log
mhawryluk May 22, 2026
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
2 changes: 1 addition & 1 deletion src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3765,7 +3765,7 @@ const CONST = {
DOWNLOAD_CSV: 'downloadCSV',
SETTINGS: 'settings',
EXPORT: 'export',
SYNC_WITH_GUSTO: 'syncWithGusto',
SYNC_WITH_HR: 'syncWithHR',
},
MEMBERS_BULK_ACTION_TYPES: {
REMOVE: 'remove',
Expand Down
2 changes: 2 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ const ONYXKEYS = {
// object should mirror the data as it's stored in the database.
POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED: 'policyHasConnectionsDataBeenFetched_',
POLICY_CONNECTION_SYNC_PROGRESS: 'policyConnectionSyncProgress_',
POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN: 'policyMergeHRInitialSyncModalShown_',
WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_',
WORKSPACE_INVITE_ROLE_DRAFT: 'workspaceInviteRoleDraft_',
Expand Down Expand Up @@ -1351,6 +1352,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStepDeprecated;
[ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER]: OnyxTypes.PolicyJoinMember;
[ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress;
[ONYXKEYS.COLLECTION.POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN]: string;
[ONYXKEYS.COLLECTION.SNAPSHOT]: OnyxTypes.SearchResults;
[ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT]: OnyxTypes.AgentPrompt;
[ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod;
Expand Down
48 changes: 48 additions & 0 deletions src/hooks/useMergeHRInitialSyncingModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {useEffect, useEffectEvent} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {setMergeHRInitialSyncModalShown} from '@libs/actions/connections/MergeHR';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PolicyConnectionSyncProgress} from '@src/types/onyx/Policy';
import useConfirmModal from './useConfirmModal';
import useLocalize from './useLocalize';
import useOnyx from './useOnyx';

/**
* Shows a one-time informational modal when the Merge HR connection's first backend-initiated sync starts.
* The modal is suppressed for subsequent page loads during the same sync by persisting the sync timestamp in Onyx.
*/
function useMergeHRInitialSyncingModal(policyID: string, connectionSyncProgress: OnyxEntry<PolicyConnectionSyncProgress>, isFocused: boolean) {
const {showConfirmModal} = useConfirmModal();
const {translate} = useLocalize();
const [shownForTimestamp] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN}${policyID}`);

const showSyncingModal = useEffectEvent((timestamp: string) => {
if (shownForTimestamp === timestamp) {
return;
}
setMergeHRInitialSyncModalShown(policyID, timestamp);
showConfirmModal({
id: `merge-hr-syncing-${policyID}`,
title: translate('workspace.hr.syncingModalTitle'),
prompt: translate('workspace.hr.syncingModalDescription'),
confirmText: translate('common.buttonConfirm'),
shouldShowCancelButton: false,
});
});

useEffect(() => {
const isMergeHRInitialSyncStarting =
connectionSyncProgress?.connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR &&
connectionSyncProgress?.stageInProgress === CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.MERGE_HR_SYNC_TITLE &&
connectionSyncProgress?.isInitialSync;

if (!isFocused || !isMergeHRInitialSyncStarting || !connectionSyncProgress?.timestamp) {
return;
}
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.

[isInitialSyncInProgress, isFocused] @mhawryluk have you tried passing in isInitialSyncInProgress rather than rerender on both changes?


showSyncingModal(connectionSyncProgress.timestamp);
}, [connectionSyncProgress?.connectionName, connectionSyncProgress?.stageInProgress, connectionSyncProgress?.isInitialSync, connectionSyncProgress?.timestamp, isFocused]);
}

export default useMergeHRInitialSyncingModal;
6 changes: 4 additions & 2 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6096,8 +6096,8 @@ const translations = {
addedWithPrimary: 'Some members were added with their primary logins.',
invitedBySecondaryLogin: (secondaryLogin: string) => `Added by secondary login ${secondaryLogin}.`,
workspaceMembersCount: (count: number) => `Total workspace members: ${count}`,
configureGustoSync: 'Configure Gusto sync.',
syncWithGusto: 'Sync with Gusto',
configureHRSync: (providerName: string) => `Configure ${providerName} sync.`,
syncWithHR: (providerName: string) => `Sync with ${providerName}`,
allMembers: 'All members',
admins: 'Admins',
approvers: 'Approvers',
Expand Down Expand Up @@ -6458,6 +6458,8 @@ const translations = {
finalApprover: 'Final approver',
providerFinalApprover: (providerName: string) => `${providerName} final approver`,
notSet: 'Not set',
syncingModalTitle: 'Your connection is syncing',
syncingModalDescription: "The first connection can take some time. You'll be notified of any errors.",
approvalModeDescription: (providerName: string) => `Members and managers are set up to sync with ${providerName}.`,
approvalModeWarningTitle: 'Change approval mode?',
approvalModeWarningPrompt: (providerName: string, helpSiteURL: string) =>
Expand Down
2 changes: 1 addition & 1 deletion src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ type HRConnectionName = TupleToUnion<typeof CONST.POLICY.CONNECTIONS.HR_CONNECTI
/** Display info for an HR provider connected to a policy. */
type HRProviderInfo = {
/** The internal connection name used as the key on `policy.connections` (e.g. `'gusto'`, `'zenefits'`, `'merge_hris'`). */
connectionName: string;
connectionName: ConnectionName;

/** Human-readable label shown in the UI (e.g. `'Gusto'`, `'TriNet'`, or a Merge HR provider brand like `'Workday'`). */
displayName: string;
Expand Down
6 changes: 5 additions & 1 deletion src/libs/actions/connections/MergeHR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ function updateMergeHRFinalApprover(policyID: string, finalApprover: string | nu
);
}

function setMergeHRInitialSyncModalShown(policyID: string, timestamp: string) {
Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN}${policyID}`, timestamp);
}

type HRProviderName = TupleToUnion<typeof CONST.POLICY.CONNECTIONS.HR_CONNECTION_NAMES>;

type HRConnectionErrorFieldName = 'approvalMode' | 'finalApprover';
Expand All @@ -203,6 +207,6 @@ function clearHRConnectionErrorField(policyID: string | undefined, provider: HRP
});
}

export {syncMergeHR, updateMergeHRApprovalMode, updateMergeHRFinalApprover, clearHRConnectionErrorField};
export {syncMergeHR, updateMergeHRApprovalMode, updateMergeHRFinalApprover, clearHRConnectionErrorField, setMergeHRInitialSyncModalShown};

export default getMergeHRSetupLink;
35 changes: 19 additions & 16 deletions src/pages/workspace/WorkspaceMembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {isPersonalDetailsReady, sortAlphabetically} from '@libs/OptionsListUtils
import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils';
import {
canEditWorkspaceSettings,
getConnectedHRProvider,
getConnectionExporters,
getMemberAccountIDsForWorkspace,
isControlPolicy,
Expand Down Expand Up @@ -582,10 +583,10 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
}, [isLoading, policy?.employeeList, translate, isOfflineAndNoMemberDataAvailable]);

const memberCount = data.filter((member) => member.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length;
const hasGustoConnection = !!policy?.connections?.gusto;
const shouldShowGustoSyncLink = isPolicyAdmin && hasGustoConnection;
const isGustoSyncInProgress =
hasGustoConnection && connectionSyncProgress?.connectionName === CONST.POLICY.CONNECTIONS.NAME.GUSTO && isConnectionInProgress(connectionSyncProgress, policy);
const connectedHRProvider = getConnectedHRProvider(policy);
const shouldShowHRSyncLink = isPolicyAdmin && !!connectedHRProvider;
const isHRSyncInProgress =
shouldShowHRSyncLink && connectionSyncProgress?.connectionName === connectedHRProvider?.connectionName && isConnectionInProgress(connectionSyncProgress, policy);
const isPendingAddOrDelete =
isOffline && data?.some((member) => member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
const shouldShowSearchBar = data.length > CONST.SEARCH_ITEM_LIMIT;
Expand Down Expand Up @@ -633,16 +634,18 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
<View style={[styles.pl5, styles.mb5, styles.mt3, styles.flexRow, styles.alignItemsCenter]}>
<Text style={[styles.textSupporting, styles.flexShrink1, isPendingAddOrDelete && styles.offlineFeedbackPending]}>
{translate('workspace.people.workspaceMembersCount', memberCount)}
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.

@mhawryluk do we hide the three dot modal during initial sync?

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.

no, there's a spinner in its place

{shouldShowGustoSyncLink && '. '}
{shouldShowGustoSyncLink && (
<TextLink onPress={() => Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID))}>{translate('workspace.people.configureGustoSync')}</TextLink>
{shouldShowHRSyncLink && '. '}
{shouldShowHRSyncLink && (
<TextLink onPress={() => Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID))}>
{translate('workspace.people.configureHRSync', connectedHRProvider?.displayName ?? '')}
</TextLink>
)}
</Text>
{shouldShowGustoSyncLink && isGustoSyncInProgress && (
{shouldShowHRSyncLink && isHRSyncInProgress && (
<ActivityIndicator
size="small"
style={styles.ml2}
reasonAttributes={{context: 'WorkspaceMembersPage.gustoSync'}}
reasonAttributes={{context: 'WorkspaceMembersPage.hrSync'}}
/>
)}
</View>
Expand Down Expand Up @@ -841,20 +844,21 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
},
];

if (hasGustoConnection) {
const hrProvider = getConnectedHRProvider(policy);
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)

getConnectedHRProvider(policy) is already called at the component body level (~line 586) and stored in connectedHRProvider. Calling it again here inside the useMemo callback with the same policy argument duplicates logic. Reuse the existing connectedHRProvider variable instead:

if (connectedHRProvider) {
    menuItems.push({
        icon: icons.Sync,
        text: translate('workspace.people.syncWithHR', connectedHRProvider.displayName),
        ...
        close(() => syncConnection(policy, connectedHRProvider.connectionName));
    });
}

Add connectedHRProvider to the dependency array if not already tracked.


Reviewed at: 38199e7 | 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.

it causes an eslint/react-compiler error, because we pass both policy and connectedHRProvider which depends on policy

if (hrProvider) {
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.

We already have connectedHRProvider defined above, so let's reuse that.

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.

when I put connectedHRProvider in the dependency array there is an error saying that react-compiler cannot preserve the memoization, so I would leave it for now

menuItems.push({
icon: icons.Sync,
text: translate('workspace.people.syncWithGusto'),
text: translate('workspace.people.syncWithHR', hrProvider.displayName),
onSelected: () => {
if (isOffline) {
close(showRequiresInternetModal);
return;
}

close(() => syncConnection(policy, CONST.POLICY.CONNECTIONS.NAME.GUSTO));
close(() => syncConnection(policy, hrProvider.connectionName));
},
value: CONST.POLICY.SECONDARY_ACTIONS.SYNC_WITH_GUSTO,
disabled: isGustoSyncInProgress,
value: CONST.POLICY.SECONDARY_ACTIONS.SYNC_WITH_HR,
disabled: isHRSyncInProgress,
});
}

Expand All @@ -870,8 +874,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
policyID,
showLockedAccountModal,
showRequiresInternetModal,
hasGustoConnection,
isGustoSyncInProgress,
isHRSyncInProgress,
policy,
]);

Expand Down
2 changes: 2 additions & 0 deletions src/pages/workspace/hr/WorkspaceHRPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Text from '@components/Text';
import useGustoSyncResultsModal from '@hooks/useGustoSyncResultsModal';
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useMergeHRInitialSyncingModal from '@hooks/useMergeHRInitialSyncingModal';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
Expand Down Expand Up @@ -59,6 +60,7 @@ function WorkspaceHRPage({
}, [policyID]);

useGustoSyncResultsModal(policyID, connectionSyncProgress, isFocused);
useMergeHRInitialSyncingModal(policyID, connectionSyncProgress, isFocused);

const cards = getHRCards({
policy,
Expand Down
3 changes: 3 additions & 0 deletions src/types/onyx/Policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2373,6 +2373,9 @@ type PolicyConnectionSyncProgress = {

/** Optional result payload shown after a completed sync */
result?: GustoSyncResult;

/** Whether this is the initial sync after the connection was established */
isInitialSync?: boolean;
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.

instead of a boolean, we'll have an enum named syncType which will have 'initial' value

};

export default Policy;
Expand Down
Loading