Skip to content

[TriNet] Release 3: Wire up approval workflows for HR integrations #90586

@yuwenmemon

Description

@yuwenmemon

Parent: https://github.com/Expensify/Expensify/issues/625136
Design doc: https://docs.google.com/document/d/1TGdZYwm4_fvPOZtUziiz27bwVRqYm6HuEGopUYWNg8Y/edit

Scope

Make the Workflows page HR-aware so it reflects TriNet (or Gusto) approval rules without becoming the source of truth:

  • Locked approvals toggle — toggle shows "on" with a lock icon; tapping shows a "Not so fast..." modal.
  • "Configure via TriNet" link — navigates to the HR page.
  • Mode-by-mode behavior:
    • Basic: single read-only approver chain, (from TriNet) badge, no "Add approval workflow."
    • Manager: read-only synced manager chains, (from TriNet) badge per row, no "Add approval workflow."
    • Custom: toggle still locked, but the approval table is fully editable; "Add approval workflow" available.
  • Deep-link guards on Edit / Create / Limit / ExpensesFrom approval pages — redirect back when in read-only Basic/Manager mode.

Depends on: Release 1 issues — specifically isZenefitsConnected, CONST.ZENEFITS.APPROVAL_MODE, and ZenefitsConnectionConfig type.

Implementation notes (from design doc)

src/libs/PolicyUtils.ts — HR workflow helpers

function isHRReadOnlyWorkflowMode(policy: OnyxEntry<Policy>): boolean {
    const zenefitsMode = policy?.connections?.zenefits?.config?.approvalMode;
    const gustoMode = policy?.connections?.gusto?.config?.approvalMode;
    const activeMode = zenefitsMode ?? gustoMode;
    return !!activeMode &&
        activeMode !== CONST.ZENEFITS.APPROVAL_MODE.CUSTOM &&
        activeMode !== CONST.GUSTO.APPROVAL_MODE.CUSTOM;
}

function isHRIntegrationConnected(policy: OnyxEntry<Policy>): boolean {
    return isGustoConnected(policy) || isZenefitsConnected(policy);
}

function getHRConnectionDisplayName(
    policy: OnyxEntry<Policy>,
    translate: LocaleContextProps['translate'],
): string {
    if (isZenefitsConnected(policy)) return translate('workspace.hr.zenefits.title');
    if (isGustoConnected(policy)) return translate('workspace.hr.gusto.title');
    return '';
}

Update getDefaultApprover:

function getDefaultApprover(policy: OnyxEntry<Policy>): string {
    const zenefitsConfig = policy?.connections?.zenefits?.config;
    if (
        zenefitsConfig?.approvalMode &&
        zenefitsConfig.approvalMode !== CONST.ZENEFITS.APPROVAL_MODE.CUSTOM &&
        zenefitsConfig.finalApprover
    ) {
        return zenefitsConfig.finalApprover;
    }

    const gustoConfig = policy?.connections?.gusto?.config;
    if (
        gustoConfig?.approvalMode &&
        gustoConfig.approvalMode !== CONST.GUSTO.APPROVAL_MODE.CUSTOM &&
        gustoConfig.finalApprover
    ) {
        return gustoConfig.finalApprover;
    }

    return policy?.approver || policy?.owner || '';
}

This automatically flows through convertPolicyEmployeesToApprovalWorkflows in WorkflowUtils.ts.

src/components/ApprovalWorkflowSection.tsx — Add badgeText and isReadOnly props

type ApprovalWorkflowSectionProps = {
    approvalWorkflow: ApprovalWorkflow;
    onPress: () => void;
    currency?: string;
    badgeText?: string;
    isReadOnly?: boolean;
};

Changes:

  • Disable onPress when isReadOnly.
  • Remove accessibilityRole="button" when read-only.
  • Hide ArrowRight icon when read-only.
  • Show badgeText as a supporting label.
  • Inner MenuItem onPress handlers also respect isReadOnly.

src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx — Core changes

New imports:

import {
    isHRIntegrationConnected,
    isHRReadOnlyWorkflowMode,
    getHRConnectionDisplayName,
} from '@libs/PolicyUtils';

Derived state:

const isHRConnected = isHRIntegrationConnected(policy);
const isHRReadOnly = isHRReadOnlyWorkflowMode(policy);
const hrDisplayName = isHRConnected ? getHRConnectionDisplayName(policy, translate) : '';

Update approvals toggle in optionItems:

disabled: isSmartLimitEnabled || isDEWEnabled || isHRConnected,
showLockIcon: isHRConnected,
disabledAction: isHRConnected
    ? () => {
        showConfirmModal({
            title: translate('workspace.hr.notSoFast'),
            prompt: translate('workspace.hr.approvalsLockedByHR', { integration: hrDisplayName }),
            confirmText: translate('workspace.hr.configureVia', { integration: hrDisplayName }),
            cancelText: translate('common.cancel'),
        }).then((result) => {
            if (result.action !== ModalActions.CONFIRM) return;
            Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(route.params.policyID));
        });
    }
    : undefined,

Update isActive:

isActive:
    isHRConnected ||
    isDEWEnabled ||
    (([CONST.POLICY.APPROVAL_MODE.BASIC, CONST.POLICY.APPROVAL_MODE.ADVANCED]
        .some((approvalMode) => approvalMode === policy?.approvalMode) && !hasApprovalError) ?? false),

Update subtitle:

subtitle:
    isHRConnected
        ? translate('workspace.hr.approvalsConfiguredByHR', { integration: hrDisplayName })
        : isSmartLimitEnabled
            ? translate('workspace.moreFeatures.workflows.disableApprovalPrompt')
            : translate('workflowsPage.addApprovalsDescription'),

Update renderOptionItem to forward showLockIcon, disabledAction, disabledText.

Update ApprovalWorkflowSection usage:

<ApprovalWorkflowSection
    approvalWorkflow={workflow}
    onPress={() =>
        Navigation.navigate(
            ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(
                route.params.policyID,
                workflow.approvers.at(0)?.email ?? '',
            ),
        )
    }
    currency={policy?.outputCurrency}
    badgeText={
        isHRConnected && isHRReadOnly
            ? translate('workspace.hr.fromIntegration', { integration: hrDisplayName })
            : undefined
    }
    isReadOnly={isHRConnected && isHRReadOnly}
/>

Conditionally hide "Add approval workflow":

{!(isHRConnected && isHRReadOnly) && (
    <MenuItem
        title={translate('workflowsPage.addApprovalButton')}
        ...existing props...
    />
)}

Approval sub-page guards

Apply isHRReadOnlyWorkflowMode(policy) to shouldShowNotFoundView on each of:

  • WorkspaceWorkflowsApprovalsEditPage.tsx
  • WorkspaceWorkflowsApprovalsCreatePage.tsx
  • WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx
  • WorkspaceWorkflowsApprovalsExpensesFromPage.tsx

These pages are reachable via deep-link and must also block editing in Basic/Manager mode.

src/languages/en.ts (+ other locales) — New strings

notSoFast: 'Not so fast...'
approvalsLockedByHR:
    ({integration}: {integration: string}) =>
        `Approval workflows are currently managed by your ${integration} connection. To change the approval mode or final approver, go to the HR page.`
approvalsConfiguredByHR:
    ({integration}: {integration: string}) =>
        `Approval workflows are configured via ${integration}.`
configureVia:
    ({integration}: {integration: string}) =>
        `Configure via ${integration}`
fromIntegration:
    ({integration}: {integration: string}) =>
        `(from ${integration})`

Offline behavior

  • Locked toggle: always visible from cached Onyx; lock icon and modal work offline.
  • Read-only chains: visible from cached data.
  • "Configure via" navigation: works offline.
  • Deep-link guards: work offline because they rely on cached policy data.

Tests

  • PolicyUtilsTest.ts: cases for isHRReadOnlyWorkflowMode, isHRIntegrationConnected, getHRConnectionDisplayName, and the updated getDefaultApprover (covering Basic/Manager/Custom modes for both Gusto and Zenefits).
  • WorkflowUtilsTest.ts: cases for final-approver fallback in Basic/Manager modes vs. Custom mode.
  • ApprovalWorkflowSection component test: badge rendering, read-only state disables onPress.
  • Manual: Manual Test Doc Release 3 section in full.
Issue OwnerCurrent Issue Owner: @ShridharGoel

Metadata

Metadata

Labels

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions