diff --git a/.reports/embedded-react-sdk.api.md b/.reports/embedded-react-sdk.api.md index fc6fb4a8d..aa342917e 100644 --- a/.reports/embedded-react-sdk.api.md +++ b/.reports/embedded-react-sdk.api.md @@ -1786,6 +1786,10 @@ declare namespace ContractorManagement { ProfileEditFormProps_2 as ProfileEditFormProps, PaymentFlow, PaymentFlowProps, + CreatePaymentFlow, + CreatePaymentFlowProps, + ViewPaymentFlow, + ViewPaymentFlowProps, PaymentsList, PaymentsListProps, CreatePayment, @@ -2085,6 +2089,14 @@ type CreatableTimeOffPolicyType = Extract; // @public function CreatePayment(props: CreatePaymentProps): JSX; +// @alpha +function CreatePaymentFlow(props: CreatePaymentFlowProps): JSX; + +// @alpha +interface CreatePaymentFlowProps extends BaseComponentInterface { + companyId: string; +} + // @public interface CreatePaymentProps extends BaseComponentInterface<'Contractor.Payments.CreatePayment'> { companyId: string; @@ -6280,6 +6292,14 @@ interface ViewHolidayScheduleProps extends BaseComponentInterface<'Company.TimeO companyId: string; } +// @alpha +function ViewPaymentFlow(props: ViewPaymentFlowProps): JSX; + +// @alpha +interface ViewPaymentFlowProps extends BaseComponentInterface { + paymentId: string; +} + // @public export const WageType: { readonly Fixed: "Fixed"; diff --git a/docs/guides/endpoint-inventory.json b/docs/guides/endpoint-inventory.json index ba63ef1aa..e715e13aa 100644 --- a/docs/guides/endpoint-inventory.json +++ b/docs/guides/endpoint-inventory.json @@ -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", diff --git a/docs/guides/endpoint-reference.md b/docs/guides/endpoint-reference.md index c6d20fa51..bf9ee3aa4 100644 --- a/docs/guides/endpoint-reference.md +++ b/docs/guides/endpoint-reference.md @@ -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 | diff --git a/e2e/main.tsx b/e2e/main.tsx index 91cc2d9aa..f343f96cf 100644 --- a/e2e/main.tsx +++ b/e2e/main.tsx @@ -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' @@ -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': diff --git a/e2e/tests/contractor/93-payment-submit.spec.ts b/e2e/tests/contractor/93-payment-submit.spec.ts index 25f5b23b3..ef06ce014 100644 --- a/e2e/tests/contractor/93-payment-submit.spec.ts +++ b/e2e/tests/contractor/93-payment-submit.spec.ts @@ -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, }) }) diff --git a/e2e/utils/contractorFlowDrivers.ts b/e2e/utils/contractorFlowDrivers.ts index 72d2edafe..e5b50bf4f 100644 --- a/e2e/utils/contractorFlowDrivers.ts +++ b/e2e/utils/contractorFlowDrivers.ts @@ -574,7 +574,9 @@ async function reviewAndSubmitPayment(page: Page): Promise { } async function assertPaymentSummary(page: Page): Promise { - 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( diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlow.tsx b/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlow.tsx new file mode 100644 index 000000000..821c9fbda --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlow.tsx @@ -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 ( + * {}} + * /> + * ) + * } + * ``` + */ +export function CreatePaymentFlow(props: CreatePaymentFlowProps) { + return +} + +/** + * 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 +} diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlowComponents.tsx b/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlowComponents.tsx new file mode 100644 index 000000000..bc2c1301c --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlowComponents.tsx @@ -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 { + /** 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() + return +} + +/** @internal */ +export function PaymentSummaryContextual() { + const { createdPaymentGroupId, companyId, onEvent, alerts } = + useFlow() + + return ( + + ) +} diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/GUIDE.md b/src/components/Contractor/Payments/CreatePaymentFlow/GUIDE.md new file mode 100644 index 000000000..602813488 --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/GUIDE.md @@ -0,0 +1,16 @@ + + +# CreatePaymentFlow + +## Step flow + +`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. diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.test.ts b/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.test.ts new file mode 100644 index 000000000..165a6aef6 --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.test.ts @@ -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, type: string, payload?: unknown) { + ;(service.send as SendFunction)({ type, payload }) +} + +function currentBreadcrumbId(service: ReturnType) { + 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) { + 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' }, + ]) + }) + }) +}) diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.ts b/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.ts new file mode 100644 index 000000000..e7c3a6a1c --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.ts @@ -0,0 +1,91 @@ +import { reduce, state, transition } from 'robot3' +import type { ContractorPaymentGroup } from '@gusto/embedded-api/models/components/contractorpaymentgroup' +import type { WireInRequest } from '@gusto/embedded-api/models/components/wireinrequest' +import { + PaymentSummaryContextual, + type CreatePaymentFlowContextInterface, +} from './CreatePaymentFlowComponents' +import { payrollWireEvents, componentEvents } from '@/shared/constants' +import type { MachineEventType, MachineTransition } from '@/types/Helpers' +import { updateBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import type { BreadcrumbNodes } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' + +type EventPayloads = { + [componentEvents.CONTRACTOR_PAYMENT_CREATED]: ContractorPaymentGroup + [payrollWireEvents.PAYROLL_WIRE_FORM_DONE]: { + wireInRequest: WireInRequest + confirmationAlert: { + title: string + content?: string + } + } +} + +/** @internal */ +export const createPaymentBreadcrumbsNodes: BreadcrumbNodes = { + createPayment: { + parent: null, + item: { + id: 'createPayment', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.CreatePayment', + onNavigate: ((ctx: CreatePaymentFlowContextInterface) => ({ + ...updateBreadcrumbs('createPayment', ctx), + })) as (context: unknown) => unknown, + }, + }, + paymentSummary: { + parent: null, + item: { + id: 'paymentSummary', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.PaymentSummary', + }, + }, +} + +/** @internal */ +export const createPaymentMachine = { + createPayment: state( + transition( + componentEvents.CONTRACTOR_PAYMENT_CREATED, + 'paymentSummary', + reduce( + ( + ctx: CreatePaymentFlowContextInterface, + ev: MachineEventType, + ): CreatePaymentFlowContextInterface => { + return { + ...updateBreadcrumbs('paymentSummary', ctx), + component: PaymentSummaryContextual, + createdPaymentGroupId: ev.payload.uuid, + alerts: undefined, + } + }, + ), + ), + ), + paymentSummary: state( + transition( + payrollWireEvents.PAYROLL_WIRE_FORM_DONE, + 'paymentSummary', + reduce( + ( + ctx: CreatePaymentFlowContextInterface, + ev: MachineEventType, + ): CreatePaymentFlowContextInterface => { + return { + ...ctx, + alerts: [ + { + type: 'success', + title: 'wireDetailsSubmitted', + content: ev.payload.confirmationAlert.content, + }, + ], + } + }, + ), + ), + ), +} diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/index.ts b/src/components/Contractor/Payments/CreatePaymentFlow/index.ts new file mode 100644 index 000000000..d1853f13d --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/index.ts @@ -0,0 +1,2 @@ +export { CreatePaymentFlow } from './CreatePaymentFlow' +export type { CreatePaymentFlowProps } from './CreatePaymentFlowComponents' diff --git a/src/components/Contractor/Payments/PaymentFlow/PaymentFlow.test.tsx b/src/components/Contractor/Payments/PaymentFlow/PaymentFlow.test.tsx new file mode 100644 index 000000000..13917a10c --- /dev/null +++ b/src/components/Contractor/Payments/PaymentFlow/PaymentFlow.test.tsx @@ -0,0 +1,309 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { http, HttpResponse } from 'msw' +import { PaymentFlow } from './PaymentFlow' +import type { PaymentFlowProps } from './PaymentFlowComponents' +import { server } from '@/test/mocks/server' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { API_BASE_URL } from '@/test/constants' +import { handleGetContractorPaymentGroup } from '@/test/mocks/apis/contractor_payment_groups' +import { handleGetContractorsList } from '@/test/mocks/apis/contractors' +import { + handleGetInformationRequests, + handleSubmitInformationRequest, +} from '@/test/mocks/apis/information_requests' +import { componentEvents, informationRequestEvents } from '@/shared/constants' + +const COMPANY_ID = 'company-123' + +/** + * `CreatePayment` (reached via the createPayment breadcrumb test) queries payment + * speed and the contractor payment receipt; neither has a default handler in + * `handlers.ts`, so both need an explicit mock to avoid an unhandled request. + */ +const paymentConfigsMock = http.get( + `${API_BASE_URL}/v1/companies/:company_uuid/payment_configs`, + () => HttpResponse.json({ payment_speed: '2-day', fast_payment_limit: 5000000 }), +) + +const contractorPaymentReceiptNotAvailable = http.get( + `${API_BASE_URL}/v1/contractor_payments/:contractor_payment_uuid/receipt`, + () => new HttpResponse(null, { status: 404 }), +) + +/** + * Overrides the default `getContractorPaymentGroup` fixture with a contractor + * payment that carries `may_cancel: true`, so the history row's action menu + * surfaces both "View" and "Cancel payment". + */ +const paymentGroupWithCancelableContractorPayment = handleGetContractorPaymentGroup(() => + HttpResponse.json({ + uuid: 'payment-group-123', + company_uuid: '123', + check_date: '2025-01-30', + debit_date: '2025-01-28', + status: 'Funded', + contractor_payments: [ + { + uuid: 'contractor-payment-1', + contractor_uuid: 'contractor-123', + wage_type: 'Hourly', + hourly_rate: '50.00', + hours: '10.00', + bonus: '0.00', + reimbursement: '0.00', + wage_total: '500.00', + payment_method: 'Direct Deposit', + status: 'Funded', + may_cancel: true, + }, + ], + totals: { + wages: '500.00', + reimbursements: '0.00', + benefits: '0.00', + taxes: '0.00', + net_pay: '500.00', + debit_amount: '500.00', + }, + }), +) + +const hourlyContractor = { + uuid: 'contractor-hourly', + company_uuid: COMPANY_ID, + wage_type: 'Hourly', + type: 'Individual', + first_name: 'Ada', + last_name: 'Lovelace', + is_active: true, + onboarding_status: 'onboarding_completed', + hourly_rate: '50.00', + payment_method: 'Direct Deposit', +} + +const singleTextInformationRequest = handleGetInformationRequests(() => + HttpResponse.json([ + { + uuid: 'rfi-1', + company_uuid: COMPANY_ID, + type: 'company_onboarding', + status: 'pending_response', + blocking_payroll: false, + required_questions: [ + { + question_uuid: 'q-1', + question_text: 'Please confirm.', + response_type: 'text', + }, + ], + }, + ]), +) + +/** + * Wraps `PaymentFlow` behind a sibling button that forces a parent re-render. + * Simulates a partner app re-rendering its own tree on every bubbled `onEvent` + * call -- independent of anything `PaymentFlow` itself controls -- to check + * that the flow's in-progress state survives being re-rendered from above. + */ +function PaymentFlowHost({ onEvent }: { onEvent: PaymentFlowProps['onEvent'] }) { + const [, setTick] = useState(0) + return ( +
+ + +
+ ) +} + +describe('PaymentFlow', () => { + beforeEach(() => { + setupApiTestMocks() + server.use(paymentConfigsMock, contractorPaymentReceiptNotAvailable) + }) + + it('walks from the payments list through history and a payment statement, back via breadcrumb, then cancels', async () => { + const user = userEvent.setup() + const onEvent = vi.fn() + server.use(paymentGroupWithCancelableContractorPayment) + + renderWithProviders() + + // Landing -> History + await user.click(await screen.findByRole('button', { name: 'View payment' })) + await screen.findByRole('heading', { name: 'Contractor payment history' }) + expect(onEvent).toHaveBeenCalledWith(componentEvents.CONTRACTOR_PAYMENT_VIEW, { + paymentId: 'payment-group-123', + }) + + // History -> Statement + await user.click(screen.getByRole('button', { name: 'Action' })) + await user.click(await screen.findByRole('menuitem', { name: 'View' })) + await screen.findByRole('heading', { name: 'Payment statement for John Contractor' }) + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, + expect.objectContaining({ paymentGroupId: 'payment-group-123' }), + ) + + // Statement -> History, via the "Payment history" breadcrumb + await user.click(screen.getByRole('button', { name: 'Payment history' })) + await screen.findByRole('heading', { name: 'Contractor payment history' }) + + // History -> Landing, via cancelling the payment + await user.click(screen.getByRole('button', { name: 'Action' })) + await user.click(await screen.findByRole('menuitem', { name: 'Cancel payment' })) + + await screen.findByRole('heading', { name: 'Contractor payments' }) + expect(await screen.findByText('Contractor payment cancelled successfully')).toBeInTheDocument() + expect(onEvent).toHaveBeenCalledWith(componentEvents.CONTRACTOR_PAYMENT_CANCEL, { + paymentId: 'contractor-payment-1', + }) + }) + + it('returns to the payments list when the breadcrumb is clicked from history', async () => { + const user = userEvent.setup() + const onEvent = vi.fn() + server.use(paymentGroupWithCancelableContractorPayment) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'View payment' })) + await screen.findByRole('heading', { name: 'Contractor payment history' }) + + await user.click(screen.getByRole('button', { name: 'Contractor payments' })) + + await screen.findByRole('heading', { name: 'Contractor payments' }) + expect(screen.getByRole('button', { name: 'View payment' })).toBeInTheDocument() + }) + + it('returns to the payments list when the breadcrumb is clicked from createPayment', async () => { + const user = userEvent.setup() + const onEvent = vi.fn() + + renderWithProviders() + + await screen.findByRole('heading', { name: 'Contractor payments' }) + await user.click(screen.getByRole('button', { name: 'New payment' })) + + await screen.findByRole('heading', { name: 'Pay contractors' }) + await user.click(screen.getByRole('button', { name: 'Contractor payments' })) + + await screen.findByRole('heading', { name: 'Contractor payments' }) + }) + + it('creates a payment via CreatePaymentFlow and lands on the summary', async () => { + const user = userEvent.setup() + const onEvent = vi.fn() + server.use( + handleGetContractorsList(() => + HttpResponse.json([hourlyContractor], { + headers: { 'x-total-pages': '1', 'x-total-count': '1' }, + }), + ), + ) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'New payment' })) + await screen.findByRole('heading', { name: 'Pay contractors' }) + + await user.click(await screen.findByRole('button', { name: 'Edit contractor payment' })) + await user.click(await screen.findByRole('menuitem', { name: 'Edit contractor payment' })) + await screen.findByRole('heading', { name: 'Edit contractor pay' }) + await user.type(screen.getByLabelText('Hours'), '8') + await user.click(screen.getByRole('button', { name: 'Done' })) + await waitFor(() => { + expect(screen.queryByRole('heading', { name: 'Edit contractor pay' })).not.toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Continue' })) + await screen.findByRole('heading', { name: 'Review and submit' }) + + await user.click(screen.getByRole('button', { name: 'Submit' })) + + await screen.findByRole('heading', { name: 'Payment summary' }) + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_PAYMENT_CREATED, + expect.objectContaining({ uuid: 'new-payment-group-uuid' }), + ) + }) + + it('enters the information-requests flow and cancels back to the payments list', async () => { + const user = userEvent.setup() + const onEvent = vi.fn() + server.use(singleTextInformationRequest) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'Respond' })) + await screen.findByRole('heading', { name: 'Information requests' }) + + await user.click(await screen.findByRole('button', { name: 'Respond' })) + await screen.findByText('Request for information') + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await screen.findByRole('heading', { name: 'Contractor payments' }) + expect(onEvent).toHaveBeenCalledWith( + informationRequestEvents.INFORMATION_REQUEST_FORM_CANCEL, + undefined, + ) + }) + + it('completes the information-requests flow and returns to the payments list', async () => { + const user = userEvent.setup() + const onEvent = vi.fn() + server.use( + singleTextInformationRequest, + handleSubmitInformationRequest(() => + HttpResponse.json({ + uuid: 'rfi-1', + company_uuid: COMPANY_ID, + type: 'company_onboarding', + status: 'pending_review', + blocking_payroll: false, + }), + ), + ) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'Respond' })) + await screen.findByRole('heading', { name: 'Information requests' }) + + await user.click(await screen.findByRole('button', { name: 'Respond' })) + await screen.findByText('Request for information') + await user.type(screen.getByPlaceholderText('Your answer'), 'Confirm') + await user.click(screen.getByRole('button', { name: 'Submit response' })) + + await screen.findByRole('heading', { name: 'Contractor payments' }) + expect(onEvent).toHaveBeenCalledWith( + informationRequestEvents.INFORMATION_REQUEST_FORM_DONE, + expect.objectContaining({ uuid: 'rfi-1', status: 'pending_review' }), + ) + }) + + it("does not reset the flow's current step when the partner app re-renders its own tree mid-flow", async () => { + const user = userEvent.setup() + const onEvent = vi.fn() + server.use(paymentGroupWithCancelableContractorPayment) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'View payment' })) + await screen.findByRole('heading', { name: 'Contractor payment history' }) + + await user.click(screen.getByRole('button', { name: 'force parent re-render' })) + + expect(screen.getByRole('heading', { name: 'Contractor payment history' })).toBeInTheDocument() + }) +}) diff --git a/src/components/Contractor/Payments/PaymentFlow/PaymentFlowComponents.tsx b/src/components/Contractor/Payments/PaymentFlow/PaymentFlowComponents.tsx index b1d192e1f..ae4bf1ca9 100644 --- a/src/components/Contractor/Payments/PaymentFlow/PaymentFlowComponents.tsx +++ b/src/components/Contractor/Payments/PaymentFlow/PaymentFlowComponents.tsx @@ -1,13 +1,10 @@ import { PaymentsListInternal } from '../PaymentsList/PaymentsList' -import { CreatePayment } from '../CreatePayment/CreatePayment' -import { PaymentHistory } from '../PaymentHistory/PaymentHistory' -import { PaymentStatement } from '../PaymentStatement/PaymentStatement' -import { PaymentSummaryInternal } from '../PaymentSummary/PaymentSummary' +import { CreatePaymentInternalFlow } from '../CreatePaymentFlow/CreatePaymentFlow' +import { ViewPaymentInternalFlow } from '../ViewPaymentFlow/ViewPaymentFlow' import type { InternalAlert } from '../types' import { InformationRequestsFlow } from '@/components/InformationRequests' import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' import type { BaseComponentInterface } from '@/components/Base' -import type { BreadcrumbTrail } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' import { ensureRequired } from '@/helpers/ensureRequired' /** @@ -23,10 +20,7 @@ export interface PaymentFlowProps extends BaseComponentInterface { /** @internal */ export interface PaymentFlowContextInterface extends FlowContextInterface { companyId: string - breadcrumbs?: BreadcrumbTrail currentPaymentId?: string - currentContractorUuid?: string - createdPaymentGroupId?: string alerts?: InternalAlert[] } @@ -38,42 +32,37 @@ export function PaymentListContextual() { ) } -/** @internal */ -export function CreatePaymentContextual() { - const { companyId, onEvent } = useFlow() - return +function useLandingPrefixBreadcrumbs() { + const { header } = useFlow() + const landingBreadcrumb = + header?.type === 'breadcrumbs' ? header.breadcrumbs?.landing?.[0] : undefined + return landingBreadcrumb ? [landingBreadcrumb] : undefined } /** @internal */ -export function PaymentHistoryContextual() { - const { currentPaymentId, onEvent } = useFlow() - return -} +export function CreatePaymentFlowContextual() { + const { companyId, onEvent } = useFlow() + const prefixBreadcrumbs = useLandingPrefixBreadcrumbs() -/** @internal */ -export function PaymentStatementContextual() { - const { currentPaymentId, currentContractorUuid, onEvent } = - useFlow() return ( - ) } /** @internal */ -export function PaymentSummaryContextual() { - const { createdPaymentGroupId, companyId, onEvent, alerts } = - useFlow() +export function ViewPaymentFlowContextual() { + const { currentPaymentId, onEvent } = useFlow() + const prefixBreadcrumbs = useLandingPrefixBreadcrumbs() return ( - ) } diff --git a/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.test.ts b/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.test.ts new file mode 100644 index 000000000..51912663e --- /dev/null +++ b/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import { createMachine, interpret, type SendFunction } from 'robot3' +import { paymentMachine, paymentFlowBreadcrumbsNodes } from './paymentStateMachine' +import type { PaymentFlowContextInterface } from './PaymentFlowComponents' +import { componentEvents, informationRequestEvents, payrollWireEvents } from '@/shared/constants' +import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import { ensureRequired } from '@/helpers/ensureRequired' + +function createTestMachine() { + return createMachine( + 'landing', + paymentMachine, + (initialContext: PaymentFlowContextInterface): PaymentFlowContextInterface => ({ + ...initialContext, + component: () => null, + companyId: 'test-company', + header: { + type: 'breadcrumbs', + breadcrumbs: buildBreadcrumbs(paymentFlowBreadcrumbsNodes), + }, + }), + ) +} + +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, type: string, payload?: unknown) { + ;(service.send as SendFunction)({ type, payload }) +} + +function navigateToLanding(service: ReturnType) { + send(service, componentEvents.BREADCRUMB_NAVIGATE, { + key: 'landing', + onNavigate: ensureRequired(paymentFlowBreadcrumbsNodes.landing).item.onNavigate, + }) +} + +describe('paymentMachine', () => { + describe('landing state', () => { + it('transitions to createPayment on CONTRACTOR_PAYMENT_CREATE', () => { + const service = createService() + expect(service.machine.current).toBe('landing') + + send(service, componentEvents.CONTRACTOR_PAYMENT_CREATE) + + expect(service.machine.current).toBe('createPayment') + expect(service.context.alerts).toBeUndefined() + }) + + it('transitions to history on CONTRACTOR_PAYMENT_VIEW with paymentId from event', () => { + const service = createService() + + send(service, componentEvents.CONTRACTOR_PAYMENT_VIEW, { paymentId: 'payment-123' }) + + expect(service.machine.current).toBe('history') + expect(service.context.currentPaymentId).toBe('payment-123') + expect(service.context.alerts).toBeUndefined() + }) + + it('transitions to informationRequests on CONTRACTOR_PAYMENT_RFI_RESPOND', () => { + const service = createService() + + send(service, componentEvents.CONTRACTOR_PAYMENT_RFI_RESPOND) + + expect(service.machine.current).toBe('informationRequests') + }) + + it('sets a success alert on PAYROLL_WIRE_FORM_DONE and stays on landing', () => { + const service = createService() + + send(service, payrollWireEvents.PAYROLL_WIRE_FORM_DONE, { + wireInRequest: {}, + confirmationAlert: { title: 'wireDetailsSubmitted', content: 'Wire submitted' }, + }) + + expect(service.machine.current).toBe('landing') + expect(service.context.alerts).toEqual([ + { type: 'success', title: 'wireDetailsSubmitted', content: 'Wire submitted' }, + ]) + }) + }) + + describe('createPayment state', () => { + function toCreatePayment(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_CREATE) + expect(service.machine.current).toBe('createPayment') + } + + it('stays active for the full lifetime of the CreatePaymentFlow spoke (no internal transitions)', () => { + const service = createService() + toCreatePayment(service) + + expect(service.context.component).toBeDefined() + }) + + it('transitions to landing on CONTRACTOR_PAYMENT_EXIT bubbled from the spoke', () => { + const service = createService() + toCreatePayment(service) + + send(service, componentEvents.CONTRACTOR_PAYMENT_EXIT, { uuid: 'group-456' }) + + expect(service.machine.current).toBe('landing') + expect(service.context.alerts).toBeUndefined() + }) + + it('transitions to landing on the bubbled landing BREADCRUMB_NAVIGATE', () => { + const service = createService() + toCreatePayment(service) + + navigateToLanding(service) + + expect(service.machine.current).toBe('landing') + }) + }) + + describe('history state', () => { + function toHistory(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_VIEW, { paymentId: 'payment-123' }) + expect(service.machine.current).toBe('history') + } + + it('transitions to landing with a success alert on CONTRACTOR_PAYMENT_CANCEL bubbled from the spoke', () => { + const service = createService() + toHistory(service) + + send(service, componentEvents.CONTRACTOR_PAYMENT_CANCEL, { paymentId: 'payment-123' }) + + expect(service.machine.current).toBe('landing') + expect(service.context.alerts).toEqual([ + { type: 'success', title: 'paymentCancelledSuccessfully' }, + ]) + }) + + it('transitions to landing on the bubbled landing BREADCRUMB_NAVIGATE', () => { + const service = createService() + toHistory(service) + + navigateToLanding(service) + + expect(service.machine.current).toBe('landing') + }) + }) + + describe('informationRequests state', () => { + function toInformationRequests(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_RFI_RESPOND) + expect(service.machine.current).toBe('informationRequests') + } + + it('transitions to landing on INFORMATION_REQUEST_FORM_DONE', () => { + const service = createService() + toInformationRequests(service) + + send(service, informationRequestEvents.INFORMATION_REQUEST_FORM_DONE) + + expect(service.machine.current).toBe('landing') + }) + + it('transitions to landing on INFORMATION_REQUEST_FORM_CANCEL', () => { + const service = createService() + toInformationRequests(service) + + send(service, informationRequestEvents.INFORMATION_REQUEST_FORM_CANCEL) + + expect(service.machine.current).toBe('landing') + }) + }) +}) diff --git a/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.ts b/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.ts index cfcb58e2f..4fa196c59 100644 --- a/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.ts +++ b/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.ts @@ -1,32 +1,22 @@ import { reduce, state, transition } from 'robot3' -import type { ContractorPaymentGroup } from '@gusto/embedded-api/models/components/contractorpaymentgroup' -import type { Contractor } from '@gusto/embedded-api/models/components/contractor' import type { WireInRequest } from '@gusto/embedded-api/models/components/wireinrequest' -import { getContractorDisplayName } from '../../shared/helpers' import { - CreatePaymentContextual, - type PaymentFlowContextInterface, - PaymentHistoryContextual, - PaymentListContextual, - PaymentStatementContextual, - PaymentSummaryContextual, + CreatePaymentFlowContextual, InformationRequestsContextual, + PaymentListContextual, + type PaymentFlowContextInterface, + ViewPaymentFlowContextual, } from './PaymentFlowComponents' import { componentEvents, informationRequestEvents, payrollWireEvents } from '@/shared/constants' import type { MachineEventType, MachineTransition } from '@/types/Helpers' -import { patchBreadcrumbsHeader, updateBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import { patchBreadcrumbsHeader } from '@/helpers/breadcrumbHelpers' import type { BreadcrumbNodes } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' import { createBreadcrumbNavigateTransition } from '@/components/Common/FlowBreadcrumbs/breadcrumbTransitionHelpers' type EventPayloads = { [componentEvents.CONTRACTOR_PAYMENT_CREATE]: undefined - [componentEvents.CONTRACTOR_PAYMENT_CREATED]: ContractorPaymentGroup [componentEvents.CONTRACTOR_PAYMENT_EXIT]: { uuid?: string | null } [componentEvents.CONTRACTOR_PAYMENT_VIEW]: { paymentId: string } - [componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS]: { - contractor: Contractor - paymentGroupId: string - } [componentEvents.CONTRACTOR_PAYMENT_CANCEL]: { paymentId: string } [componentEvents.CONTRACTOR_PAYMENT_RFI_RESPOND]: undefined [componentEvents.BREADCRUMB_NAVIGATE]: { @@ -44,7 +34,16 @@ type EventPayloads = { } } -/** @internal */ +/** + * Hub-level breadcrumb nodes for {@link PaymentFlow}. + * + * @remarks + * Only `landing` is defined here — the `createPayment` and `history` spokes own and render their + * own breadcrumb trails (via `CreatePaymentFlow`/`ViewPaymentFlow`), prefixed with this `landing` + * item so the trail reads continuously across the hub/spoke boundary. + * + * @internal + */ export const paymentFlowBreadcrumbsNodes: BreadcrumbNodes = { landing: { parent: null, @@ -58,54 +57,23 @@ export const paymentFlowBreadcrumbsNodes: BreadcrumbNodes = { })) as (context: unknown) => unknown, }, }, - createPayment: { - parent: 'landing', - item: { - id: 'createPayment', - label: 'breadcrumbLabel', - namespace: 'Contractor.Payments.CreatePayment', - onNavigate: ((ctx: PaymentFlowContextInterface) => ({ - ...updateBreadcrumbs('createPayment', ctx), - })) as (context: unknown) => unknown, - }, - }, - paymentSummary: { - parent: 'landing', - item: { - id: 'paymentSummary', - label: 'breadcrumbLabel', - namespace: 'Contractor.Payments.PaymentSummary', - onNavigate: ((ctx: PaymentFlowContextInterface) => ({ - ...updateBreadcrumbs('paymentSummary', ctx), - })) as (context: unknown) => unknown, - }, - }, - history: { - parent: 'landing', - item: { - id: 'history', - label: 'breadcrumbLabel', - namespace: 'Contractor.Payments.PaymentHistory', - onNavigate: ((ctx: PaymentFlowContextInterface) => ({ - ...updateBreadcrumbs('history', ctx), - component: PaymentHistoryContextual, - })) as (context: unknown) => unknown, - }, - }, - statement: { - parent: 'history', - item: { - id: 'statement', - label: 'breadcrumbLabel', - namespace: 'Contractor.Payments.PaymentStatement', - }, - }, -} as const +} const breadcrumbNavigateTransition = createBreadcrumbNavigateTransition() -/** @internal */ +/** + * Hub machine for {@link PaymentFlow}. + * + * @remarks + * `createPayment` and `history` each stay active for the full lifetime of their respective spoke + * (`CreatePaymentFlow`, `ViewPaymentFlow`) rather than tracking the spoke's internal screen. The + * spoke's own machine drives its internal steps and breadcrumb trail; events it can't handle + * locally (e.g. the `landing` breadcrumb, or a terminal exit/cancel event) bubble up here to + * transition the hub back to `landing`. + * + * @internal + */ export const paymentMachine = { landing: state( transition( @@ -113,8 +81,8 @@ export const paymentMachine = { 'createPayment', reduce((ctx: PaymentFlowContextInterface): PaymentFlowContextInterface => { return { - ...updateBreadcrumbs('createPayment', ctx), - component: CreatePaymentContextual, + ...ctx, + component: CreatePaymentFlowContextual, alerts: undefined, } }), @@ -128,8 +96,8 @@ export const paymentMachine = { ev: MachineEventType, ): PaymentFlowContextInterface => { return { - ...updateBreadcrumbs('history', ctx), - component: PaymentHistoryContextual, + ...ctx, + component: ViewPaymentFlowContextual, currentPaymentId: ev.payload.paymentId, alerts: undefined, } @@ -169,26 +137,6 @@ export const paymentMachine = { ), ), createPayment: state( - transition( - componentEvents.CONTRACTOR_PAYMENT_CREATED, - 'paymentSummary', - reduce( - ( - ctx: PaymentFlowContextInterface, - ev: MachineEventType, - ): PaymentFlowContextInterface => { - return { - ...updateBreadcrumbs('paymentSummary', ctx), - component: PaymentSummaryContextual, - createdPaymentGroupId: ev.payload.uuid, - alerts: undefined, - } - }, - ), - ), - breadcrumbNavigateTransition('landing'), - ), - paymentSummary: state( transition( componentEvents.CONTRACTOR_PAYMENT_EXIT, 'landing', @@ -196,58 +144,13 @@ export const paymentMachine = { return { ...patchBreadcrumbsHeader(ctx, { currentBreadcrumbId: undefined }), component: PaymentListContextual, - createdPaymentGroupId: undefined, alerts: undefined, } }), ), - transition( - payrollWireEvents.PAYROLL_WIRE_FORM_DONE, - 'paymentSummary', - reduce( - ( - ctx: PaymentFlowContextInterface, - ev: MachineEventType, - ): PaymentFlowContextInterface => { - return { - ...ctx, - alerts: [ - { - type: 'success', - title: 'wireDetailsSubmitted', - content: ev.payload.confirmationAlert.content, - }, - ], - } - }, - ), - ), breadcrumbNavigateTransition('landing'), ), history: state( - transition( - componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, - 'statement', - reduce( - ( - ctx: PaymentFlowContextInterface, - ev: MachineEventType< - EventPayloads, - typeof componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS - >, - ): PaymentFlowContextInterface => { - return { - ...updateBreadcrumbs('statement', ctx, { - contractorName: getContractorDisplayName(ev.payload.contractor), - }), - component: PaymentStatementContextual, - currentContractorUuid: ev.payload.contractor.uuid, - currentPaymentId: ev.payload.paymentGroupId, - alerts: undefined, - } - }, - ), - ), transition( componentEvents.CONTRACTOR_PAYMENT_CANCEL, 'landing', @@ -266,10 +169,6 @@ export const paymentMachine = { ), breadcrumbNavigateTransition('landing'), ), - statement: state( - breadcrumbNavigateTransition('landing'), - breadcrumbNavigateTransition('history'), - ), informationRequests: state( transition( informationRequestEvents.INFORMATION_REQUEST_FORM_DONE, diff --git a/src/components/Contractor/Payments/ViewPaymentFlow/GUIDE.md b/src/components/Contractor/Payments/ViewPaymentFlow/GUIDE.md new file mode 100644 index 000000000..ae02b2f8a --- /dev/null +++ b/src/components/Contractor/Payments/ViewPaymentFlow/GUIDE.md @@ -0,0 +1,17 @@ + + +# ViewPaymentFlow + +## Step flow + +`ViewPaymentFlow` centers on `PaymentHistory` as its hub: it shows a payment group's details and can either drill into an individual contractor's statement (`contractor/payments/view/details` → `PaymentStatement`) or cancel the group outright (`contractor/payments/cancel`), which exits the flow. + +```mermaid +flowchart TD + start@{ shape: sm-circ } --> PaymentHistory + PaymentHistory -->|"contractor/payments/view/details"| PaymentStatement + PaymentStatement -->|"breadcrumb/navigate"| PaymentHistory + PaymentHistory -->|"contractor/payments/cancel"| done@{ shape: fr-circ, label: " " } +``` + +The breadcrumb header (`breadcrumb/navigate`) returns from `PaymentStatement` to `PaymentHistory`, or exits the flow entirely from either step. diff --git a/src/components/Contractor/Payments/ViewPaymentFlow/ViewPaymentFlow.tsx b/src/components/Contractor/Payments/ViewPaymentFlow/ViewPaymentFlow.tsx new file mode 100644 index 000000000..44d16def8 --- /dev/null +++ b/src/components/Contractor/Payments/ViewPaymentFlow/ViewPaymentFlow.tsx @@ -0,0 +1,116 @@ +import { createMachine } from 'robot3' +import { useState } from 'react' +import { viewPaymentBreadcrumbsNodes, viewPaymentMachine } from './viewPaymentMachine' +import { + PaymentHistoryContextual, + type ViewPaymentFlowContextInterface, + type ViewPaymentFlowProps, +} from './ViewPaymentFlowComponents' +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 ViewPaymentInternalFlow}, which layers a parent flow's + * prefix breadcrumbs on top of the public {@link ViewPaymentFlowProps}. + * + * @internal + */ +export interface ViewPaymentInternalFlowProps extends ViewPaymentFlowProps { + /** + * 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 inspect a contractor payment group's history and drill into an individual + * contractor's payment statement. + * + * @remarks + * This is the inner flow that powers the view-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 payment-viewing experience without re-implementing it. The flow ships with + * breadcrumb navigation and lets the user cancel an individual payment from the history screen. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `contractor/payments/view/details` | Fired when the user views a specific contractor payment | `{ contractor: Contractor, paymentGroupId: string }` | + * | `contractor/payments/cancel` | Fired when a payment is cancelled | `{ paymentId: string }` | + * | `breadcrumb/navigate` | Fired when the user clicks a breadcrumb to navigate back | `{ key: string, onNavigate: (ctx) => ctx }` | + * + * @components + * - {@link PaymentHistory} + * - {@link PaymentStatement} + * + * @param props - See {@link ViewPaymentFlowProps}. + * @returns The composed view-payment flow. + * @alpha + * + * @example + * ```tsx title="App.tsx" + * import { ContractorManagement } from '@gusto/embedded-react-sdk' + * + * function MyApp() { + * return ( + * {}} + * /> + * ) + * } + * ``` + */ +export function ViewPaymentFlow(props: ViewPaymentFlowProps) { + return +} + +/** + * Flow-internal entry point for {@link ViewPaymentFlow} that additionally accepts + * flow-injected `prefixBreadcrumbs`. Partners use {@link ViewPaymentFlow}; `PaymentFlow` renders + * this directly to prepend its own breadcrumb trail. + * + * @internal + */ +export function ViewPaymentInternalFlow({ + paymentId, + onEvent, + prefixBreadcrumbs = EMPTY_BREADCRUMBS, +}: ViewPaymentInternalFlowProps) { + // Built once via a lazy useState initializer, not useMemo -- see CreatePaymentFlow.tsx for why: + // a useMemo keyed on `prefixBreadcrumbs` doesn't guarantee the machine's identity survives a + // re-render triggered by a bubbled `onEvent` call. + const [viewPaymentFlow] = useState(() => { + const baseBreadcrumbs = buildBreadcrumbs(viewPaymentBreadcrumbsNodes) + const breadcrumbs = Object.fromEntries( + Object.entries(baseBreadcrumbs).map(([stateKey, trail]) => [ + stateKey, + [...prefixBreadcrumbs, ...trail], + ]), + ) + + const initialBreadcrumbContext = updateBreadcrumbs('history', { + header: { + type: 'breadcrumbs' as const, + breadcrumbs, + }, + }) + + return createMachine( + 'history', + viewPaymentMachine, + (initialContext: ViewPaymentFlowContextInterface) => ({ + ...initialContext, + ...initialBreadcrumbContext, + component: PaymentHistoryContextual, + currentPaymentId: paymentId, + }), + ) + }) + + return +} diff --git a/src/components/Contractor/Payments/ViewPaymentFlow/ViewPaymentFlowComponents.tsx b/src/components/Contractor/Payments/ViewPaymentFlow/ViewPaymentFlowComponents.tsx new file mode 100644 index 000000000..b37ee05bd --- /dev/null +++ b/src/components/Contractor/Payments/ViewPaymentFlow/ViewPaymentFlowComponents.tsx @@ -0,0 +1,40 @@ +import { PaymentHistory } from '../PaymentHistory/PaymentHistory' +import { PaymentStatement } from '../PaymentStatement/PaymentStatement' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import type { BaseComponentInterface } from '@/components/Base' +import { ensureRequired } from '@/helpers/ensureRequired' + +/** + * Props for {@link ViewPaymentFlow}. + * + * @alpha + */ +export interface ViewPaymentFlowProps extends BaseComponentInterface { + /** Identifier of the payment group to inspect. */ + paymentId: string +} + +/** @internal */ +export interface ViewPaymentFlowContextInterface extends FlowContextInterface { + currentPaymentId?: string + currentContractorUuid?: string +} + +/** @internal */ +export function PaymentHistoryContextual() { + const { currentPaymentId, onEvent } = useFlow() + return +} + +/** @internal */ +export function PaymentStatementContextual() { + const { currentPaymentId, currentContractorUuid, onEvent } = + useFlow() + return ( + + ) +} diff --git a/src/components/Contractor/Payments/ViewPaymentFlow/index.ts b/src/components/Contractor/Payments/ViewPaymentFlow/index.ts new file mode 100644 index 000000000..0060b28a8 --- /dev/null +++ b/src/components/Contractor/Payments/ViewPaymentFlow/index.ts @@ -0,0 +1,2 @@ +export { ViewPaymentFlow } from './ViewPaymentFlow' +export type { ViewPaymentFlowProps } from './ViewPaymentFlowComponents' diff --git a/src/components/Contractor/Payments/ViewPaymentFlow/viewPaymentMachine.test.ts b/src/components/Contractor/Payments/ViewPaymentFlow/viewPaymentMachine.test.ts new file mode 100644 index 000000000..d46b76a57 --- /dev/null +++ b/src/components/Contractor/Payments/ViewPaymentFlow/viewPaymentMachine.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { createMachine, interpret, type SendFunction } from 'robot3' +import { viewPaymentMachine, viewPaymentBreadcrumbsNodes } from './viewPaymentMachine' +import type { ViewPaymentFlowContextInterface } from './ViewPaymentFlowComponents' +import { componentEvents } from '@/shared/constants' +import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import { ensureRequired } from '@/helpers/ensureRequired' + +function createTestMachine() { + return createMachine( + 'history', + viewPaymentMachine, + (initialContext: ViewPaymentFlowContextInterface): ViewPaymentFlowContextInterface => ({ + ...initialContext, + component: () => null, + currentPaymentId: 'payment-123', + header: { + type: 'breadcrumbs', + breadcrumbs: buildBreadcrumbs(viewPaymentBreadcrumbsNodes), + currentBreadcrumbId: 'history', + }, + }), + ) +} + +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, type: string, payload?: unknown) { + ;(service.send as SendFunction)({ type, payload }) +} + +function currentBreadcrumbId(service: ReturnType) { + return service.context.header?.type === 'breadcrumbs' + ? service.context.header.currentBreadcrumbId + : undefined +} + +describe('viewPaymentMachine', () => { + describe('history state', () => { + it('transitions to statement on CONTRACTOR_PAYMENT_VIEW_DETAILS with contractor and payment ids', () => { + const service = createService() + const contractor = { uuid: 'contractor-789', firstName: 'Ada', lastName: 'Lovelace' } + + send(service, componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, { + contractor, + paymentGroupId: 'group-456', + }) + + expect(service.machine.current).toBe('statement') + expect(service.context.currentContractorUuid).toBe('contractor-789') + expect(service.context.currentPaymentId).toBe('group-456') + expect(currentBreadcrumbId(service)).toBe('statement') + }) + }) + + describe('statement state', () => { + function toStatement(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, { + contractor: { uuid: 'contractor-789', firstName: 'Ada', lastName: 'Lovelace' }, + paymentGroupId: 'group-456', + }) + expect(service.machine.current).toBe('statement') + } + + it('transitions to history on BREADCRUMB_NAVIGATE with history key', () => { + const service = createService() + toStatement(service) + + send(service, componentEvents.BREADCRUMB_NAVIGATE, { + key: 'history', + onNavigate: ensureRequired(viewPaymentBreadcrumbsNodes.history).item.onNavigate, + }) + + expect(service.machine.current).toBe('history') + }) + + it('ignores BREADCRUMB_NAVIGATE with an unrelated key', () => { + const service = createService() + toStatement(service) + + send(service, componentEvents.BREADCRUMB_NAVIGATE, { key: 'landing' }) + + expect(service.machine.current).toBe('statement') + }) + }) +}) diff --git a/src/components/Contractor/Payments/ViewPaymentFlow/viewPaymentMachine.ts b/src/components/Contractor/Payments/ViewPaymentFlow/viewPaymentMachine.ts new file mode 100644 index 000000000..fdd654bd4 --- /dev/null +++ b/src/components/Contractor/Payments/ViewPaymentFlow/viewPaymentMachine.ts @@ -0,0 +1,76 @@ +import { reduce, state, transition } from 'robot3' +import type { Contractor } from '@gusto/embedded-api/models/components/contractor' +import { getContractorDisplayName } from '../../shared/helpers' +import { + PaymentHistoryContextual, + PaymentStatementContextual, + type ViewPaymentFlowContextInterface, +} from './ViewPaymentFlowComponents' +import { componentEvents } from '@/shared/constants' +import type { MachineEventType, MachineTransition } from '@/types/Helpers' +import { updateBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import type { BreadcrumbNodes } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' +import { createBreadcrumbNavigateTransition } from '@/components/Common/FlowBreadcrumbs/breadcrumbTransitionHelpers' + +type EventPayloads = { + [componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS]: { + contractor: Contractor + paymentGroupId: string + } +} + +const breadcrumbNavigateTransition = + createBreadcrumbNavigateTransition() + +/** @internal */ +export const viewPaymentBreadcrumbsNodes: BreadcrumbNodes = { + history: { + parent: null, + item: { + id: 'history', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.PaymentHistory', + onNavigate: ((ctx: ViewPaymentFlowContextInterface) => ({ + ...updateBreadcrumbs('history', ctx), + component: PaymentHistoryContextual, + })) as (context: unknown) => unknown, + }, + }, + statement: { + parent: 'history', + item: { + id: 'statement', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.PaymentStatement', + }, + }, +} + +/** @internal */ +export const viewPaymentMachine = { + history: state( + transition( + componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, + 'statement', + reduce( + ( + ctx: ViewPaymentFlowContextInterface, + ev: MachineEventType< + EventPayloads, + typeof componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS + >, + ): ViewPaymentFlowContextInterface => { + return { + ...updateBreadcrumbs('statement', ctx, { + contractorName: getContractorDisplayName(ev.payload.contractor), + }), + component: PaymentStatementContextual, + currentContractorUuid: ev.payload.contractor.uuid, + currentPaymentId: ev.payload.paymentGroupId, + } + }, + ), + ), + ), + statement: state(breadcrumbNavigateTransition('history')), +} diff --git a/src/components/Contractor/exports/contractorManagement.ts b/src/components/Contractor/exports/contractorManagement.ts index 33a853769..a4b6259b4 100644 --- a/src/components/Contractor/exports/contractorManagement.ts +++ b/src/components/Contractor/exports/contractorManagement.ts @@ -6,6 +6,8 @@ export { export { Profile, ProfileCard, ProfileEditForm } from '../Profile/management' export type { ProfileProps, ProfileCardProps, ProfileEditFormProps } from '../Profile/management' export { PaymentFlow, type PaymentFlowProps } from '../Payments/PaymentFlow' +export { CreatePaymentFlow, type CreatePaymentFlowProps } from '../Payments/CreatePaymentFlow' +export { ViewPaymentFlow, type ViewPaymentFlowProps } from '../Payments/ViewPaymentFlow' export { PaymentsList, type PaymentsListProps } from '../Payments/PaymentsList/PaymentsList' export { CreatePayment, type CreatePaymentProps } from '../Payments/CreatePayment/CreatePayment' export { PaymentHistory, type PaymentHistoryProps } from '../Payments/PaymentHistory/PaymentHistory' diff --git a/src/components/InformationRequests/InformationRequestForm/index.ts b/src/components/InformationRequests/InformationRequestForm/index.ts index a6dad9f25..2e8760a70 100644 --- a/src/components/InformationRequests/InformationRequestForm/index.ts +++ b/src/components/InformationRequests/InformationRequestForm/index.ts @@ -1 +1,5 @@ -export { InformationRequestForm, type InformationRequestFormProps } from './InformationRequestForm' +export { + InformationRequestForm, + Footer, + type InformationRequestFormProps, +} from './InformationRequestForm' diff --git a/src/components/InformationRequests/InformationRequestsComponents.tsx b/src/components/InformationRequests/InformationRequestsComponents.tsx index 7fc6a8624..7d78d56a7 100644 --- a/src/components/InformationRequests/InformationRequestsComponents.tsx +++ b/src/components/InformationRequests/InformationRequestsComponents.tsx @@ -1,4 +1,4 @@ -import { InformationRequestForm } from './InformationRequestForm' +import { InformationRequestForm, Footer } from './InformationRequestForm' import { useFlow } from '@/components/Flow/useFlow' import { ensureRequired } from '@/helpers/ensureRequired' import type { FlowContextInterface } from '@/components/Flow/useFlow' @@ -31,3 +31,4 @@ export function InformationRequestFormContextual() { /> ) } +InformationRequestFormContextual.Footer = Footer