Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .reports/embedded-react-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,10 @@ declare namespace ContractorManagement {
ProfileEditFormProps_2 as ProfileEditFormProps,
PaymentFlow,
PaymentFlowProps,
CreatePaymentFlow,
CreatePaymentFlowProps,
ViewPaymentFlow,
ViewPaymentFlowProps,
PaymentsList,
PaymentsListProps,
CreatePayment,
Expand Down Expand Up @@ -2085,6 +2089,14 @@ type CreatableTimeOffPolicyType = Extract<PolicyType, 'sick' | 'vacation'>;
// @public
function CreatePayment(props: CreatePaymentProps): JSX;

// @alpha
function CreatePaymentFlow(props: CreatePaymentFlowProps): JSX;

// @alpha
interface CreatePaymentFlowProps extends BaseComponentInterface<never> {
companyId: string;
}

// @public
interface CreatePaymentProps extends BaseComponentInterface<'Contractor.Payments.CreatePayment'> {
companyId: string;
Expand Down Expand Up @@ -6280,6 +6292,14 @@ interface ViewHolidayScheduleProps extends BaseComponentInterface<'Company.TimeO
companyId: string;
}

// @alpha
function ViewPaymentFlow(props: ViewPaymentFlowProps): JSX;

// @alpha
interface ViewPaymentFlowProps extends BaseComponentInterface<never> {
paymentId: string;
}

// @public
export const WageType: {
readonly Fixed: "Fixed";
Expand Down
18 changes: 14 additions & 4 deletions docs/guides/endpoint-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -2318,16 +2318,26 @@
"EmployeeOnboarding.OnboardingFlow"
]
},
"ContractorManagement.PaymentFlow": {
"ContractorManagement.CreatePaymentFlow": {
"blocks": [
"ContractorManagement.CreatePayment",
"ContractorManagement.PaymentHistory",
"ContractorManagement.PaymentStatement",
"ContractorManagement.PaymentSummary",
"ContractorManagement.PaymentSummary"
]
},
"ContractorManagement.PaymentFlow": {
"blocks": [
"ContractorManagement.CreatePaymentFlow",
"ContractorManagement.PaymentsList",
"ContractorManagement.ViewPaymentFlow",
"InformationRequests.InformationRequestsFlow"
]
},
"ContractorManagement.ViewPaymentFlow": {
"blocks": [
"ContractorManagement.PaymentHistory",
"ContractorManagement.PaymentStatement"
]
},
"ContractorOnboarding.OnboardingFlow": {
"blocks": [
"ContractorOnboarding.Address",
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/endpoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,9 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json'

| Flow | Blocks included |
| --- | --- |
| **ContractorManagement.PaymentFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentHistory, ContractorManagement.PaymentStatement, ContractorManagement.PaymentSummary, ContractorManagement.PaymentsList, InformationRequests.InformationRequestsFlow |
| **ContractorManagement.CreatePaymentFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentSummary |
| **ContractorManagement.PaymentFlow** | ContractorManagement.CreatePaymentFlow, ContractorManagement.PaymentsList, ContractorManagement.ViewPaymentFlow, InformationRequests.InformationRequestsFlow |
| **ContractorManagement.ViewPaymentFlow** | ContractorManagement.PaymentHistory, ContractorManagement.PaymentStatement |
| **ContractorOnboarding.OnboardingFlow** | ContractorOnboarding.Address, ContractorOnboarding.ContractorList, ContractorOnboarding.ContractorProfile, ContractorOnboarding.ContractorSubmit, ContractorOnboarding.NewHireReport, ContractorOnboarding.PaymentMethod |
| **ContractorOnboarding.SelfOnboardingFlow** | ContractorOnboarding.Address, ContractorOnboarding.ContractorProfile, ContractorOnboarding.DocumentSigner, ContractorOnboarding.Landing, ContractorOnboarding.OnboardingSummary, ContractorOnboarding.PaymentMethod |

Expand Down
9 changes: 7 additions & 2 deletions e2e/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StrictMode } from 'react'
import { StrictMode, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { GustoProvider } from '@/contexts'
import { OnboardingFlow } from '@/components/Employee/OnboardingFlow/OnboardingFlow'
Expand Down Expand Up @@ -71,7 +71,12 @@ function getConfigFromUrl(): E2EConfig {
function FlowRenderer({ config }: { config: E2EConfig }) {
const { flow, companyId, employeeId, contractorId, startDate, endDate, payScheduleUuid, state } =
config
const handleEvent = () => {}
// Real partner apps may update their own state in response to SDK events, which could
// re-render the SDK's ancestor tree on every event, the same way sdk-app's event log does.
// A no-op handler here would mask bugs that only surface under that re-render churn
// (e.g. unmemoized props breaking a nested flow's useMemo).
const [, setEventCount] = useState(0)
const handleEvent = () => setEventCount(count => count + 1)

switch (flow) {
case 'employee-onboarding':
Expand Down
4 changes: 3 additions & 1 deletion e2e/tests/contractor/93-payment-submit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ test.describe.serial('ContractorCanary 03 — contractor payment end-to-end', ()

await runContractorPayment(page, scenario)

await expect(page.getByRole('heading', { name: /^payment summary$/i })).toBeVisible({
// "Payment summary" is also the review page's own totals-grid heading, so
// asserting on it alone can pass before submission actually completes.
await expect(page.getByRole('heading', { name: /^review and submit$/i })).toBeHidden({
timeout: 60_000,
})
})
Expand Down
4 changes: 3 additions & 1 deletion e2e/utils/contractorFlowDrivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@
// Snapshot check (isVisible ignores any timeout); the loop itself provides
// the retry via the 5s wait below if the row hasn't rendered yet.
if (await signButton.isVisible().catch(() => false)) {
await signButton.click()

Check failure on line 395 in e2e/utils/contractorFlowDrivers.ts

View workflow job for this annotation

GitHub Actions / e2e (contractor)

[chromium] › e2e/tests/contractor/94-self-onboarding-individual-submit.spec.ts:13:3 › ContractorCanary 04 — individual self-onboarding end-to-end › drives the seeded individual contractor through self-onboarding to "You're all set!"

1) [chromium] › e2e/tests/contractor/94-self-onboarding-individual-submit.spec.ts:13:3 › ContractorCanary 04 — individual self-onboarding end-to-end › drives the seeded individual contractor through self-onboarding to "You're all set!" Error: locator.click: Test timeout of 360000ms exceeded. Call log: - waiting for getByRole('button', { name: /^sign document$/i }).first() - locator resolved to <button data-rac="" tabindex="0" type="button" data-variant="secondary" data-react-aria-pressable="true" id="react-aria8270524053-_r_en_" class="_root_17cqt_1 react-aria-Button">Sign document</button> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not stable - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not stable - retrying click action - waiting 100ms - waiting for element to be visible, enabled and stable - element is not stable - retrying click action - waiting 500ms - waiting for element to be visible, enabled and stable - element was detached from the DOM, retrying at ../utils/contractorFlowDrivers.ts:395 393 | // the retry via the 5s wait below if the row hasn't rendered yet. 394 | if (await signButton.isVisible().catch(() => false)) { > 395 | await signButton.click() | ^ 396 | await signOpenedDocument(page) 397 | await expect(documentsHeading).toBeVisible({ timeout: LONG_WAIT }) 398 | } else { at completeDocumentsStep (/home/runner/work/embedded-react-sdk/embedded-react-sdk/e2e/utils/contractorFlowDrivers.ts:395:24) at runContractorSelfOnboarding (/home/runner/work/embedded-react-sdk/embedded-react-sdk/e2e/utils/contractorFlowDrivers.ts:423:3) at /home/runner/work/embedded-react-sdk/embedded-react-sdk/e2e/tests/contractor/94-self-onboarding-individual-submit.spec.ts:26:5
await signOpenedDocument(page)
await expect(documentsHeading).toBeVisible({ timeout: LONG_WAIT })
} else {
Expand Down Expand Up @@ -574,7 +574,9 @@
}

async function assertPaymentSummary(page: Page): Promise<void> {
await expect(page.getByRole('heading', { name: /^payment summary$/i })).toBeVisible({
// "Payment summary" is also the review page's own totals-grid heading, so
// asserting on it alone can pass before submission actually completes.
await expect(page.getByRole('heading', { name: /^review and submit$/i })).toBeHidden({
timeout: LONG_WAIT,
})
await expect(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { createMachine } from 'robot3'
import { useState } from 'react'
import { createPaymentBreadcrumbsNodes, createPaymentMachine } from './createPaymentMachine'
import {
CreatePaymentContextual,
type CreatePaymentFlowContextInterface,
type CreatePaymentFlowProps,
} from './CreatePaymentFlowComponents'
import { Flow } from '@/components/Flow/Flow'
import type { FlowBreadcrumb } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes'
import { buildBreadcrumbs, updateBreadcrumbs } from '@/helpers/breadcrumbHelpers'

const EMPTY_BREADCRUMBS: FlowBreadcrumb[] = []

/**
* Props for the flow-internal {@link CreatePaymentInternalFlow}, which layers a parent flow's
* prefix breadcrumbs on top of the public {@link CreatePaymentFlowProps}.
*
* @internal
*/
export interface CreatePaymentInternalFlowProps extends CreatePaymentFlowProps {
/**
* Breadcrumbs prepended to the flow's own breadcrumb trail. Set by a parent flow (e.g.
* `PaymentFlow`) so the breadcrumb history remains coherent across the handoff.
*/
prefixBreadcrumbs?: FlowBreadcrumb[]
}

/**
* Guided flow to create a contractor payment and review the resulting summary.
*
* @remarks
* This is the inner flow that powers the create-payment spoke of `ContractorManagement.PaymentFlow`.
* Render it directly when you have built your own payments landing page and want to hand the user
* off to the standard create-payment experience without re-implementing it. The flow ships with
* breadcrumb navigation and handles Fast ACH blockers and wire transfer requirements inline.
*
* @events
* | Event | Description | Data |
* | ----- | ----------- | ---- |
* | `contractor/payments/created` | Fired when a payment group is successfully created | The created `ContractorPaymentGroup` |
* | `contractor/payments/exit` | Fired when the user completes the payment flow | `{ uuid?: string \| null }` |
* | `payroll/wire/form/done` | Fired when wire transfer details are submitted | `{ wireInRequest: WireInRequest, confirmationAlert: { title: string, content?: string } }` |
* | `breadcrumb/navigate` | Fired when the user clicks a breadcrumb to navigate back | `{ key: string, onNavigate: (ctx) => ctx }` |
*
* @components
* - {@link CreatePayment}
* - {@link PaymentSummary}
*
* @param props - See {@link CreatePaymentFlowProps}.
* @returns The composed create-payment flow.
* @alpha
*
* @example
* ```tsx title="App.tsx"
* import { ContractorManagement } from '@gusto/embedded-react-sdk'
*
* function MyApp() {
* return (
* <ContractorManagement.CreatePaymentFlow
* companyId="a007e1ab-3595-43c2-ab4b-af7a5af2e365"
* onEvent={() => {}}
* />
* )
* }
* ```
*/
export function CreatePaymentFlow(props: CreatePaymentFlowProps) {
return <CreatePaymentInternalFlow {...props} />
}

/**
* Flow-internal entry point for {@link CreatePaymentFlow} that additionally accepts
* flow-injected `prefixBreadcrumbs`. Partners use {@link CreatePaymentFlow}; `PaymentFlow` renders
* this directly to prepend its own breadcrumb trail.
*
* @internal
*/
export function CreatePaymentInternalFlow({
companyId,
onEvent,
prefixBreadcrumbs = EMPTY_BREADCRUMBS,
}: CreatePaymentInternalFlowProps) {
// Built once via a lazy useState initializer, not useMemo: the machine's identity must survive
// re-renders no matter what, and useMemo is only a performance hint React may discard, not an
// identity guarantee. A useMemo keyed on `prefixBreadcrumbs` previously recreated this machine
// (and reset in-flight state) whenever the parent app re-rendered in response to a bubbled
// `onEvent` call, since an inline array literal upstream gets a new reference every render.
const [createPaymentFlow] = useState(() => {
const baseBreadcrumbs = buildBreadcrumbs(createPaymentBreadcrumbsNodes)
const breadcrumbs = Object.fromEntries(
Object.entries(baseBreadcrumbs).map(([stateKey, trail]) => [
stateKey,
[...prefixBreadcrumbs, ...trail],
]),
)

const initialBreadcrumbContext = updateBreadcrumbs('createPayment', {
header: {
type: 'breadcrumbs' as const,
breadcrumbs,
},
})

return createMachine(
'createPayment',
createPaymentMachine,
(initialContext: CreatePaymentFlowContextInterface) => ({
...initialContext,
...initialBreadcrumbContext,
component: CreatePaymentContextual,
companyId,
}),
)
})

return <Flow machine={createPaymentFlow} onEvent={onEvent} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { CreatePayment } from '../CreatePayment/CreatePayment'
import { PaymentSummaryInternal } from '../PaymentSummary/PaymentSummary'
import type { InternalAlert } from '../types'
import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow'
import type { BaseComponentInterface } from '@/components/Base'
import { ensureRequired } from '@/helpers/ensureRequired'

/**
* Props for {@link CreatePaymentFlow}.
*
* @alpha
*/
export interface CreatePaymentFlowProps extends BaseComponentInterface<never> {
/** The associated company identifier. */
companyId: string
}

/** @internal */
export interface CreatePaymentFlowContextInterface extends FlowContextInterface {
companyId: string
createdPaymentGroupId?: string
alerts?: InternalAlert[]
}

/** @internal */
export function CreatePaymentContextual() {
const { companyId, onEvent } = useFlow<CreatePaymentFlowContextInterface>()
return <CreatePayment onEvent={onEvent} companyId={ensureRequired(companyId)} />
}

/** @internal */
export function PaymentSummaryContextual() {
const { createdPaymentGroupId, companyId, onEvent, alerts } =
useFlow<CreatePaymentFlowContextInterface>()

return (
<PaymentSummaryInternal
onEvent={onEvent}
paymentGroupId={ensureRequired(createdPaymentGroupId)}
companyId={ensureRequired(companyId)}
alerts={alerts}
/>
)
}
16 changes: 16 additions & 0 deletions src/components/Contractor/Payments/CreatePaymentFlow/GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!-- Partner-facing guide content, published to the SDK docs site. -->

# CreatePaymentFlow

## Step flow <!-- slot: appendix -->

`CreatePaymentFlow` has no hub of its own — it's a straight line from creating a payment to reviewing the result. `CreatePayment` handles selecting a date, editing per-contractor amounts, and submitting; Fast ACH blockers and wire transfer requirements are handled inline. On success (`contractor/payments/created`) the flow hands off to `PaymentSummary`, which shows the created group, debit details, and wire instructions when required.

```mermaid
flowchart LR
start@{ shape: sm-circ } --> CreatePayment
CreatePayment -->|"contractor/payments/created"| PaymentSummary
PaymentSummary -->|"contractor/payments/exit"| done@{ shape: fr-circ, label: " " }
```

The breadcrumb header (`breadcrumb/navigate`) returns to the payments list; submitting wire-transfer details (`payroll/wire/form/done`) surfaces a success alert on `PaymentSummary` without leaving the step.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { createMachine, interpret, type SendFunction } from 'robot3'
import { createPaymentMachine, createPaymentBreadcrumbsNodes } from './createPaymentMachine'
import type { CreatePaymentFlowContextInterface } from './CreatePaymentFlowComponents'
import { componentEvents, payrollWireEvents } from '@/shared/constants'
import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers'

function createTestMachine() {
return createMachine(
'createPayment',
createPaymentMachine,
(initialContext: CreatePaymentFlowContextInterface): CreatePaymentFlowContextInterface => ({
...initialContext,
component: () => null,
companyId: 'test-company',
header: {
type: 'breadcrumbs',
breadcrumbs: buildBreadcrumbs(createPaymentBreadcrumbsNodes),
currentBreadcrumbId: 'createPayment',
},
}),
)
}

function createService() {
const machine = createTestMachine()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const service = interpret(machine, () => {}, {} as any)
return service
}

function send(service: ReturnType<typeof createService>, type: string, payload?: unknown) {
;(service.send as SendFunction<string>)({ type, payload })
}

function currentBreadcrumbId(service: ReturnType<typeof createService>) {
return service.context.header?.type === 'breadcrumbs'
? service.context.header.currentBreadcrumbId
: undefined
}

describe('createPaymentMachine', () => {
describe('createPayment state', () => {
it('transitions to paymentSummary on CONTRACTOR_PAYMENT_CREATED with the created group id', () => {
const service = createService()
expect(service.machine.current).toBe('createPayment')

send(service, componentEvents.CONTRACTOR_PAYMENT_CREATED, { uuid: 'group-456' })

expect(service.machine.current).toBe('paymentSummary')
expect(service.context.createdPaymentGroupId).toBe('group-456')
expect(currentBreadcrumbId(service)).toBe('paymentSummary')
expect(service.context.alerts).toBeUndefined()
})
})

describe('paymentSummary state', () => {
function toPaymentSummary(service: ReturnType<typeof createService>) {
send(service, componentEvents.CONTRACTOR_PAYMENT_CREATED, { uuid: 'group-456' })
expect(service.machine.current).toBe('paymentSummary')
}

it('sets a success alert on PAYROLL_WIRE_FORM_DONE and stays on paymentSummary', () => {
const service = createService()
toPaymentSummary(service)

send(service, payrollWireEvents.PAYROLL_WIRE_FORM_DONE, {
wireInRequest: {},
confirmationAlert: { title: 'wireDetailsSubmitted', content: 'Wire submitted' },
})

expect(service.machine.current).toBe('paymentSummary')
expect(service.context.alerts).toEqual([
{ type: 'success', title: 'wireDetailsSubmitted', content: 'Wire submitted' },
])
})
})
})
Loading
Loading