Skip to content
Draft
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
3 changes: 3 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6527,6 +6527,9 @@ const translations = {
alreadyConnectedPrompt: 'You must disconnect your current HR platform before connecting another.',
lastSync: (relativeDate: string) => `Last synced ${relativeDate}`,
syncError: (providerName: string) => `Can't connect to ${providerName}`,
authenticationError: (providerName: string) => `Couldn’t connect to ${providerName} due to incorrect credentials.`,
reconnect: 'Reconnect',
reconnectLink: 'Reconnect.',
connectionDescription: (providerName: string) => `Connect ${providerName} to keep employee approvals in sync with your workspace.`,
approvalMode: 'Approval mode',
providerApprovalMode: (providerName: string) => `${providerName} approval mode`,
Expand Down
23 changes: 22 additions & 1 deletion src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,8 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry<Policy>, isConnecti
shouldShowCustomUnitsError(policy) ||
shouldShowPolicyErrorFields(policy) ||
shouldShowSyncError(policy, isConnectionInProgress, getAccountingConnectionNames()) ||
shouldShowQBOReimbursableExportDestinationAccountError(policy)
shouldShowQBOReimbursableExportDestinationAccountError(policy) ||
shouldShowHRConnectionError(policy, isConnectionInProgress)
) {
return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
}
Expand Down Expand Up @@ -1959,6 +1960,25 @@ function isAnyHRConnected(policy?: OnyxEntry<Policy>): boolean {
return isGustoConnected(policy) || isZenefitsConnected(policy) || isMergeHRConnected(policy);
}

/**
* Checks if any HR connection on the policy is in an error state.
*/
function shouldShowHRConnectionError(policy: OnyxEntry<Policy>, isSyncInProgress: boolean): boolean {
if (!isPolicyAdmin(policy)) {
return false;
}
const mergeLastSync = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]?.lastSync;
if (mergeLastSync?.isAuthenticationError || mergeLastSync?.syncStatus === CONST.MERGE_HR.SYNC_STATUS.FAILED) {
return true;
}
return getHRConnectionNames().some((name) => {
if (policy?.connections?.[name]?.lastSync?.isAuthenticationError) {
return true;
}
return hasSynchronizationErrorMessage(policy, name, isSyncInProgress);
});
}

/** Returns true if any connected HR integration uses a read-only approval mode (basic or manager), which blocks manual workflow editing. */
function isAnyHRReadOnlyWorkflowMode(policy?: OnyxEntry<Policy>): boolean {
const gustoMode = policy?.connections?.gusto?.config?.approvalMode;
Expand Down Expand Up @@ -2594,6 +2614,7 @@ export {
isMergeHRConnected,
getConnectedHRProvider,
isAnyHRConnected,
shouldShowHRConnectionError,
isAnyHRReadOnlyWorkflowMode,
getHRApprovalMode,
isSubmitPolicy,
Expand Down
3 changes: 3 additions & 0 deletions src/pages/workspace/WorkspaceInitialPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
isPendingDeletePolicy,
isTimeTrackingEnabled,
shouldShowEmployeeListError,
shouldShowHRConnectionError,
shouldShowSyncError,
shouldShowTaxRateError,
} from '@libs/PolicyUtils';
Expand Down Expand Up @@ -142,6 +143,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac

const accountingConnectionNames = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES;
const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy), accountingConnectionNames);
const hasHRError = shouldShowHRConnectionError(policy, isConnectionInProgress(connectionSyncProgress, policy));
const hasMembersError = shouldShowEmployeeListError(policy);
const hasPolicyCategoryError = hasPolicyCategoriesError(policyCategories);
const hasGeneralSettingsError =
Expand Down Expand Up @@ -276,6 +278,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
translationKey: 'workspace.common.hr',
icon: expensifyIcons.Users,
action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID)))),
brickRoadIndicator: hasHRError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
screenName: SCREENS.WORKSPACE.HR,
sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.HR,
highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED,
Expand Down
26 changes: 20 additions & 6 deletions src/pages/workspace/hr/HRProviderCard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';
import type {ReactNode} from 'react';
import {View} from 'react-native';
import ActivityIndicator from '@components/ActivityIndicator';
import Button from '@components/Button';
import MenuItem from '@components/MenuItem';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import TextLink from '@components/TextLink';
import ThreeDotsMenu from '@components/ThreeDotsMenu';
import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types';
import useConfirmModal from '@hooks/useConfirmModal';
Expand All @@ -28,7 +30,7 @@ type HRProviderCardProps = {
/** The workspace policy that owns this HR integration. */
policy: Policy | undefined;

/** Callback invoked when the user taps the "Connect" button for an unconnected provider. */
/** Callback invoked when the user taps the "Connect" or "Reconnect" button. */
handleConnect: () => void;
};

Expand All @@ -46,21 +48,33 @@ function HRProviderCard({card, policy, handleConnect}: HRProviderCardProps) {
let connectionDescription: string | undefined;
if (card.isSyncInProgress) {
connectionDescription = card.syncStageInProgress ? translate('workspace.hr.syncStageName', {stage: card.syncStageInProgress}) : translate('workspace.hr.syncing');
} else if (card.successfulDate && !card.hasError) {
} else if (card.successfulDate && !card.hasError && !card.needsReconnect) {
connectionDescription = translate('workspace.hr.lastSync', datetimeToRelative(card.successfulDate));
}

let lastSyncErrorMessage: string | undefined;
if (card.hasError) {
let lastSyncErrorMessage: ReactNode | undefined;
if (card.needsReconnect) {
lastSyncErrorMessage = (
<>
{`${translate('workspace.hr.authenticationError', card.displayName)} `}
<TextLink
style={[styles.link, styles.fontSizeLabel]}
onPress={handleConnect}
>
{translate('workspace.hr.reconnectLink')}
</TextLink>
</>
);
} else if (card.hasError) {
const genericError = translate('workspace.hr.syncError', card.displayName);
lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError;
}

const overflowMenu: ThreeDotsMenuProps['menuItems'] = [
{
icon: icons.Sync,
text: translate('workspace.hr.syncNow'),
onSelected: () => syncConnection(policy, card.connectionName),
text: card.needsReconnect ? translate('workspace.hr.reconnect') : translate('workspace.hr.syncNow'),
onSelected: () => (card.needsReconnect ? handleConnect() : syncConnection(policy, card.connectionName)),
disabled: isOffline,
},
{
Expand Down
14 changes: 7 additions & 7 deletions src/pages/workspace/hr/WorkspaceHRPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ function WorkspaceHRPage({

const shouldBeBlocked = !HR_BETAS.some(isBetaEnabled);

const handleConnect = (setupLink: string | undefined) => {
if (!setupLink) {
const handleConnect = (card: HRCardDescriptor) => {
if (!card.setupLink) {
return;
}

if (connectedCards.length > 0) {
if (!card.isConnected && connectedCards.length > 0) {
showConfirmModal({
title: translate('workspace.hr.alreadyConnectedTitle'),
prompt: translate('workspace.hr.alreadyConnectedPrompt'),
Expand All @@ -105,7 +105,7 @@ function WorkspaceHRPage({
}

// eslint-disable-next-line react-hooks/purity -- random key forces remount on every press, even for the same provider
setActiveHRFlow({setupLink, key: Math.random()});
setActiveHRFlow({setupLink: card.setupLink, key: Math.random()});
};

return (
Expand Down Expand Up @@ -151,7 +151,7 @@ function WorkspaceHRPage({
key={card.key}
card={card}
policy={policy}
handleConnect={() => handleConnect(card.setupLink)}
handleConnect={() => handleConnect(card)}
/>
))}
{connectedCards.length === 0 &&
Expand All @@ -160,7 +160,7 @@ function WorkspaceHRPage({
key={card.key}
card={card}
policy={policy}
handleConnect={() => handleConnect(card.setupLink)}
handleConnect={() => handleConnect(card)}
/>
))}
</View>
Expand All @@ -177,7 +177,7 @@ function WorkspaceHRPage({
key={card.key}
card={card}
policy={policy}
handleConnect={() => handleConnect(card.setupLink)}
handleConnect={() => handleConnect(card)}
/>
))}
</CollapsibleSection>
Expand Down
10 changes: 8 additions & 2 deletions src/pages/workspace/hr/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ type HRCardDescriptor = {
/** Whether the last sync resulted in an error. */
hasError: boolean;

/** Whether the card should switch into "reconnect mode". Shows the error message and the Reconnect link. */
needsReconnect: boolean;

/** Human-readable error message from the last failed sync attempt. */
lastSyncErrorMessage?: string;

Expand Down Expand Up @@ -127,12 +130,15 @@ function getHRCardState({policy, connectionName, connectionSyncProgress, getLoca
const syncState =
connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR ? getMergeHRSyncState(policy) : getHRSyncState(policy, connectionName, connectionSyncProgress, getLocalDateFromDatetime);

const lastSyncErrorMessage = syncState.hasError ? (policy?.connections?.[connectionName]?.lastSync?.errorMessage ?? undefined) : undefined;
const lastSync = policy?.connections?.[connectionName]?.lastSync;
const lastSyncErrorMessage = syncState.hasError ? lastSync?.errorMessage : undefined;
const needsReconnect = !!lastSync?.isAuthenticationError;

return {
isConnected,
...syncState,
lastSyncErrorMessage,
needsReconnect,
};
}

Expand Down Expand Up @@ -261,7 +267,7 @@ function getHRCards({policy, connectionSyncProgress, isBetaEnabled, getLocalDate

if (isBetaEnabled(CONST.BETAS.MERGE_HR)) {
const mergeConnectionName = CONST.POLICY.CONNECTIONS.NAME.MERGE_HR;
const disconnectedState = {isConnected: false, isSyncInProgress: false, isInitialSyncInProgress: false, hasError: false} as const;
const disconnectedState = {isConnected: false, isSyncInProgress: false, isInitialSyncInProgress: false, hasError: false, needsReconnect: false} as const;

for (const [slug, providerEntry] of Object.entries(MERGE_HR_PROVIDERS) as Array<[MergeHRProviderSlug, (typeof MERGE_HR_PROVIDERS)[MergeHRProviderSlug]]>) {
const state = getHRCardState({policy, connectionName: mergeConnectionName, connectionSyncProgress, getLocalDateFromDatetime, mergeSlug: slug});
Expand Down
Loading