Skip to content
Closed
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
31 changes: 31 additions & 0 deletions src/components/Payroll/OffCycle/OffCycleFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useMemo } from 'react'
import { createMachine } from 'robot3'
import { offCycleMachine, offCycleBreadcrumbsNodes } from './offCycleStateMachine'
import {
OffCycleReasonSelectionContextual,
type OffCycleFlowContextInterface,
type OffCycleFlowProps,
} from './OffCycleFlowComponents'
import { Flow } from '@/components/Flow/Flow'
import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers'

export function OffCycleFlow({ companyId, onEvent }: OffCycleFlowProps) {
const offCycleFlowMachine = useMemo(
() =>
createMachine(
'createOffCyclePayroll',
offCycleMachine,
(initialContext: OffCycleFlowContextInterface) => ({
...initialContext,
component: OffCycleReasonSelectionContextual,
companyId,
breadcrumbs: buildBreadcrumbs(offCycleBreadcrumbsNodes),
currentBreadcrumbId: 'createOffCyclePayroll',
progressBarType: 'breadcrumbs' as const,
}),
),
[companyId],
)

return <Flow machine={offCycleFlowMachine} onEvent={onEvent} />
}
40 changes: 40 additions & 0 deletions src/components/Payroll/OffCycle/OffCycleFlowComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useMemo } from 'react'
import { OffCycleReasonSelection } from '../OffCycleReasonSelection'
import { PayrollExecutionFlow } from '../PayrollExecutionFlow/PayrollExecutionFlow'
import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow'
import type { OnEventType } from '@/components/Base/useBase'
import type { EventType } from '@/shared/constants'
import { ensureRequired } from '@/helpers/ensureRequired'

export interface OffCycleFlowContextInterface extends FlowContextInterface {
companyId: string
payrollUuid?: string
}

export interface OffCycleFlowProps {
companyId: string
onEvent: OnEventType<EventType, unknown>
}

export function OffCycleReasonSelectionContextual() {
const { companyId, onEvent } = useFlow<OffCycleFlowContextInterface>()
return <OffCycleReasonSelection companyId={ensureRequired(companyId)} onEvent={onEvent} />
}

export function OffCycleExecutionContextual() {
const { companyId, payrollUuid, onEvent, breadcrumbs } = useFlow<OffCycleFlowContextInterface>()

const prefixBreadcrumbs = useMemo(() => {
const reasonSelectionBreadcrumb = breadcrumbs?.['createOffCyclePayroll']?.[0]
return reasonSelectionBreadcrumb ? [reasonSelectionBreadcrumb] : undefined
}, [breadcrumbs])

return (
<PayrollExecutionFlow
companyId={ensureRequired(companyId)}
payrollId={ensureRequired(payrollUuid)}
onEvent={onEvent}
prefixBreadcrumbs={prefixBreadcrumbs}
/>
)
}
2 changes: 2 additions & 0 deletions src/components/Payroll/OffCycle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { OffCycleFlow } from './OffCycleFlow'
export type { OffCycleFlowContextInterface, OffCycleFlowProps } from './OffCycleFlowComponents'
75 changes: 75 additions & 0 deletions src/components/Payroll/OffCycle/offCycleStateMachine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { createMachine, interpret, type SendFunction } from 'robot3'
import { offCycleMachine, offCycleBreadcrumbsNodes } from './offCycleStateMachine'
import type { OffCycleFlowContextInterface } from './OffCycleFlowComponents'
import { componentEvents } from '@/shared/constants'
import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers'

function createTestMachine() {
return createMachine(
'createOffCyclePayroll',
offCycleMachine,
(initialContext: OffCycleFlowContextInterface) => ({
...initialContext,
component: () => null,
companyId: 'test-company',
breadcrumbs: buildBreadcrumbs(offCycleBreadcrumbsNodes),
currentBreadcrumbId: 'createOffCyclePayroll',
}),
)
}

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

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

describe('offCycleStateMachine', () => {
describe('createOffCyclePayroll state', () => {
it('starts in createOffCyclePayroll state', () => {
const service = createService()
expect(service.machine.current).toBe('createOffCyclePayroll')
})

it('transitions to execution on OFF_CYCLE_CREATED', () => {
const service = createService()

send(service, componentEvents.OFF_CYCLE_CREATED, { payrollUuid: 'payroll-123' })

expect(service.machine.current).toBe('execution')
expect(service.context.payrollUuid).toBe('payroll-123')
})
})

describe('execution state', () => {
function toExecution(service: ReturnType<typeof createService>) {
send(service, componentEvents.OFF_CYCLE_CREATED, { payrollUuid: 'payroll-123' })
expect(service.machine.current).toBe('execution')
}

it('transitions back to createOffCyclePayroll on BREADCRUMB_NAVIGATE with matching key', () => {
const service = createService()
toExecution(service)

send(service, componentEvents.BREADCRUMB_NAVIGATE, { key: 'createOffCyclePayroll' })

expect(service.machine.current).toBe('createOffCyclePayroll')
expect(service.context.payrollUuid).toBeUndefined()
})

it('ignores BREADCRUMB_NAVIGATE with non-matching key', () => {
const service = createService()
toExecution(service)

send(service, componentEvents.BREADCRUMB_NAVIGATE, { key: 'configuration' })

expect(service.machine.current).toBe('execution')
expect(service.context.payrollUuid).toBe('payroll-123')
})
})
})
62 changes: 62 additions & 0 deletions src/components/Payroll/OffCycle/offCycleStateMachine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { state, transition, reduce, guard } from 'robot3'
import {
OffCycleExecutionContextual,
OffCycleReasonSelectionContextual,
type OffCycleFlowContextInterface,
} from './OffCycleFlowComponents'
import { componentEvents } from '@/shared/constants'
import type { MachineTransition } from '@/types/Helpers'
import type { BreadcrumbNodes } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes'

export const offCycleBreadcrumbsNodes: BreadcrumbNodes = {
createOffCyclePayroll: {
parent: null,
item: {
id: 'createOffCyclePayroll',
label: 'createOffCyclePayroll.breadcrumbLabel',
namespace: 'Payroll.OffCycle',
},
},
}

function toReasonSelectionReducer(ctx: OffCycleFlowContextInterface): OffCycleFlowContextInterface {
return {
...ctx,
component: OffCycleReasonSelectionContextual,
payrollUuid: undefined,
currentBreadcrumbId: 'createOffCyclePayroll',
progressBarType: 'breadcrumbs',
}
}

const reasonSelectionBreadcrumbTransition = transition(
componentEvents.BREADCRUMB_NAVIGATE,
'createOffCyclePayroll',
guard(
(_ctx: OffCycleFlowContextInterface, ev: { payload: { key: string } }) =>
ev.payload.key === 'createOffCyclePayroll',
),
reduce(toReasonSelectionReducer),
)

export const offCycleMachine = {
createOffCyclePayroll: state<MachineTransition>(
transition(
componentEvents.OFF_CYCLE_CREATED,
'execution',
reduce(
(
ctx: OffCycleFlowContextInterface,
ev: { payload?: { payrollUuid?: string } },
): OffCycleFlowContextInterface => ({
...ctx,
payrollUuid: ev.payload?.payrollUuid,
component: OffCycleExecutionContextual,
progressBarType: null,
}),
),
),
),

execution: state<MachineTransition>(reasonSelectionBreadcrumbTransition),
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { usePayrollsGetSuspense } from '@gusto/embedded-api/react-query/payrollsGet'
import { usePayrollsCalculateMutation } from '@gusto/embedded-api/react-query/payrollsCalculate'
import type { Employee } from '@gusto/embedded-api/models/components/employee'
Expand Down Expand Up @@ -84,6 +84,14 @@ export const Root = ({
companyUuid: companyId,
})

const hasEmittedDataReady = useRef(false)
useEffect(() => {
if (payPeriod && !hasEmittedDataReady.current) {
hasEmittedDataReady.current = true
onEvent(componentEvents.RUN_PAYROLL_DATA_READY, { payPeriod })
}
}, [payPeriod, onEvent])

const payrollBlockerList = blockersData.payrollBlockerList ?? []

const blockersFromApi: ApiPayrollBlocker[] = payrollBlockerList.map(blocker => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useMemo } from 'react'
import { createMachine } from 'robot3'
import type { ConfirmWireDetailsComponentType } from '../ConfirmWireDetails/ConfirmWireDetails'
import {
PayrollConfigurationContextual,
PayrollOverviewContextual,
SaveAndExitCta,
type PayrollFlowContextInterface,
} from '../PayrollFlow/PayrollFlowComponents'
import {
payrollExecutionMachine,
payrollExecutionBreadcrumbsNodes,
} from './payrollExecutionMachine'
import { Flow } from '@/components/Flow/Flow'
import type { FlowBreadcrumb } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes'
import { buildBreadcrumbs, updateBreadcrumbs } from '@/helpers/breadcrumbHelpers'
import type { OnEventType } from '@/components/Base/useBase'
import type { EventType } from '@/shared/constants'

const EMPTY_BREADCRUMBS: FlowBreadcrumb[] = []

export type PayrollExecutionInitialState = 'configuration' | 'overview'

export interface PayrollExecutionFlowProps {
companyId: string
payrollId: string
onEvent: OnEventType<EventType, unknown>
withReimbursements?: boolean
ConfirmWireDetailsComponent?: ConfirmWireDetailsComponentType
prefixBreadcrumbs?: FlowBreadcrumb[]
initialState?: PayrollExecutionInitialState
}

const INITIAL_COMPONENT_MAP = {
configuration: PayrollConfigurationContextual,
overview: PayrollOverviewContextual,
} as const

const INITIAL_NAMESPACE_MAP = {
configuration: 'Payroll.PayrollConfiguration' as const,
overview: 'Payroll.PayrollOverview' as const,
} as const

export function PayrollExecutionFlow({
companyId,
payrollId,
onEvent,
withReimbursements = true,
ConfirmWireDetailsComponent,
prefixBreadcrumbs = EMPTY_BREADCRUMBS,
initialState = 'configuration',
}: PayrollExecutionFlowProps) {
const executionFlowMachine = useMemo(() => {
const baseBreadcrumbs = buildBreadcrumbs(payrollExecutionBreadcrumbsNodes)
const breadcrumbs = Object.fromEntries(
Object.entries(baseBreadcrumbs).map(([stateKey, trail]) => [
stateKey,
[...prefixBreadcrumbs, ...trail],
]),
)

const initialBreadcrumbContext = updateBreadcrumbs(initialState, {
breadcrumbs,
} as PayrollFlowContextInterface)

return createMachine(
initialState,
payrollExecutionMachine,
(initialContext: PayrollFlowContextInterface) => ({
...initialContext,
...initialBreadcrumbContext,
component: INITIAL_COMPONENT_MAP[initialState],
companyId,
payrollUuid: payrollId,
progressBarType: 'breadcrumbs' as const,
currentBreadcrumbId: initialState,
withReimbursements,
ConfirmWireDetailsComponent,
progressBarCta: SaveAndExitCta,
ctaConfig: {
labelKey: 'exitFlowCta',
namespace: INITIAL_NAMESPACE_MAP[initialState],
},
}),
)
}, [
companyId,
payrollId,
withReimbursements,
ConfirmWireDetailsComponent,
prefixBreadcrumbs,
initialState,
])

return <Flow machine={executionFlowMachine} onEvent={onEvent} />
}
9 changes: 9 additions & 0 deletions src/components/Payroll/PayrollExecutionFlow/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
PayrollExecutionFlow,
type PayrollExecutionFlowProps,
type PayrollExecutionInitialState,
} from './PayrollExecutionFlow'
export {
payrollExecutionMachine,
payrollExecutionBreadcrumbsNodes,
} from './payrollExecutionMachine'
Loading