Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { PayrollUpdateEmployeeCompensations } from '@gusto/embedded-api/mod
import { usePreparedPayrollData } from '../usePreparedPayrollData'
import { payrollSubmitHandler, type ApiPayrollBlocker } from '../PayrollBlocker/payrollHelpers'
import { PayrollConfigurationPresentation } from './PayrollConfigurationPresentation'
import { useBatchedMutation } from '@/hooks/useBatchedMutation'
import type { BaseComponentInterface } from '@/components/Base/Base'
import { BaseComponent } from '@/components/Base/Base'
import { componentEvents } from '@/shared/constants'
Expand Down Expand Up @@ -112,7 +113,21 @@ export const Root = ({

const { mutateAsync: calculatePayroll } = usePayrollsCalculateMutation()

const { mutateAsync: updatePayroll, isPending: isUpdatingPayroll } = usePayrollsUpdateMutation()
const { mutateAsync: baseUpdatePayroll } = usePayrollsUpdateMutation()

const { mutateAsync: updatePayroll, isPending: isUpdatingPayroll } = useBatchedMutation(
async (batch: PayrollUpdateEmployeeCompensations[]) => {
const result = await baseUpdatePayroll({
request: {
companyId,
payrollId,
payrollUpdate: { employeeCompensations: batch },
},
})
return result.payrollPrepared!
},
{ batchSize: 100 },
)

const {
preparedPayroll,
Expand Down Expand Up @@ -170,19 +185,11 @@ export const Root = ({
})
await baseSubmitHandler({}, async () => {
const transformedCompensation = transformEmployeeCompensation(employeeCompensation)
const result = await updatePayroll({
request: {
companyId,
payrollId,
payrollUpdate: {
employeeCompensations: [
{ ...transformedCompensation, excluded: !transformedCompensation.excluded },
],
},
},
})
const [result] = await updatePayroll([
{ ...transformedCompensation, excluded: !transformedCompensation.excluded },
])
onEvent(componentEvents.RUN_PAYROLL_EMPLOYEE_SAVED, {
payrollPrepared: result.payrollPrepared,
payrollPrepared: result,
})
// Refresh preparedPayroll to get updated data
await handlePreparePayroll()
Expand Down
29 changes: 18 additions & 11 deletions src/components/Payroll/PayrollEditEmployee/PayrollEditEmployee.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { PayrollUpdateEmployeeCompensations } from '@gusto/embedded-api/mod
import { useMemo } from 'react'
import { usePreparedPayrollData } from '../usePreparedPayrollData'
import { PayrollEditEmployeePresentation } from './PayrollEditEmployeePresentation'
import { useBatchedMutation } from '@/hooks/useBatchedMutation'
import { componentEvents } from '@/shared/constants'
import type { BaseComponentInterface } from '@/components/Base/Base'
import { BaseComponent } from '@/components/Base/Base'
Expand Down Expand Up @@ -44,7 +45,21 @@ export const Root = ({
employeeUuids: memoizedEmployeeId,
})

const { mutateAsync: updatePayroll, isPending } = usePayrollsUpdateMutation()
const { mutateAsync: baseUpdatePayroll } = usePayrollsUpdateMutation()

const { mutateAsync: updatePayroll, isPending } = useBatchedMutation(
async (batch: PayrollUpdateEmployeeCompensations[]) => {
const result = await baseUpdatePayroll({
request: {
companyId,
payrollId,
payrollUpdate: { employeeCompensations: batch },
},
})
return result.payrollPrepared!
},
{ batchSize: 100 },
)

const employee = employeeData.employee!
const employeeCompensation = preparedPayroll?.employeeCompensations?.at(0)
Expand All @@ -64,18 +79,10 @@ export const Root = ({
const onSave = async (updatedCompensation: PayrollEmployeeCompensationsType) => {
const transformedCompensation = transformEmployeeCompensation(updatedCompensation)
await baseSubmitHandler(null, async () => {
const result = await updatePayroll({
request: {
companyId,
payrollId,
payrollUpdate: {
employeeCompensations: [transformedCompensation],
},
},
})
const [result] = await updatePayroll([transformedCompensation])

onEvent(componentEvents.RUN_PAYROLL_EMPLOYEE_SAVED, {
payrollPrepared: result.payrollPrepared,
payrollPrepared: result,
employee,
})
})
Expand Down
167 changes: 167 additions & 0 deletions src/helpers/batchProcessor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, it, expect, vi } from 'vitest'
import { splitIntoBatches, processBatches, DEFAULT_BATCH_SIZE } from './batchProcessor'

describe('batchProcessor', () => {
describe('splitIntoBatches', () => {
it('returns empty array when given empty array', () => {
const result = splitIntoBatches([])
expect(result).toEqual([])
})

it('returns single batch when items length is less than batch size', () => {
const items = Array.from({ length: 50 }, (_, i) => i)
const result = splitIntoBatches(items)

expect(result).toHaveLength(1)
expect(result[0]).toHaveLength(50)
})

it('returns single batch when items length equals batch size', () => {
const items = Array.from({ length: 100 }, (_, i) => i)
const result = splitIntoBatches(items)

expect(result).toHaveLength(1)
expect(result[0]).toHaveLength(100)
})

it('splits items into multiple batches when length exceeds batch size', () => {
const items = Array.from({ length: 150 }, (_, i) => i)
const result = splitIntoBatches(items)

expect(result).toHaveLength(2)
expect(result[0]).toHaveLength(100)
expect(result[1]).toHaveLength(50)
})

it('splits items into exact batches when length is multiple of batch size', () => {
const items = Array.from({ length: 200 }, (_, i) => i)
const result = splitIntoBatches(items)

expect(result).toHaveLength(2)
expect(result[0]).toHaveLength(100)
expect(result[1]).toHaveLength(100)
})

it('handles custom batch sizes', () => {
const items = Array.from({ length: 75 }, (_, i) => i)
const result = splitIntoBatches(items, 25)

expect(result).toHaveLength(3)
expect(result[0]).toHaveLength(25)
expect(result[1]).toHaveLength(25)
expect(result[2]).toHaveLength(25)
})

it('throws error when batch size is zero', () => {
expect(() => splitIntoBatches([1, 2, 3], 0)).toThrow('Batch size must be greater than 0')
})

it('throws error when batch size is negative', () => {
expect(() => splitIntoBatches([1, 2, 3], -1)).toThrow('Batch size must be greater than 0')
})

it('preserves item order in batches', () => {
const items = [1, 2, 3, 4, 5]
const result = splitIntoBatches(items, 2)

expect(result).toEqual([[1, 2], [3, 4], [5]])
})
})

describe('processBatches', () => {
it('processes empty array', async () => {
const processFn = vi.fn().mockResolvedValue('result')
const result = await processBatches([], processFn)

expect(result).toEqual([])
expect(processFn).not.toHaveBeenCalled()
})

it('processes single batch when items length is less than batch size', async () => {
const items = Array.from({ length: 50 }, (_, i) => i)
const processFn = vi.fn().mockResolvedValue('result')

await processBatches(items, processFn)

expect(processFn).toHaveBeenCalledTimes(1)
expect(processFn).toHaveBeenCalledWith(items)
})

it('processes single batch when items length equals batch size', async () => {
const items = Array.from({ length: 100 }, (_, i) => i)
const processFn = vi.fn().mockResolvedValue('result')

await processBatches(items, processFn)

expect(processFn).toHaveBeenCalledTimes(1)
expect(processFn).toHaveBeenCalledWith(items)
})

it('processes multiple batches when items length exceeds batch size', async () => {
const items = Array.from({ length: 150 }, (_, i) => i)
const processFn = vi.fn().mockResolvedValue('result')

await processBatches(items, processFn)

expect(processFn).toHaveBeenCalledTimes(2)
expect(processFn).toHaveBeenNthCalledWith(1, items.slice(0, 100))
expect(processFn).toHaveBeenNthCalledWith(2, items.slice(100, 150))
})

it('returns results from all batch processing calls', async () => {
const items = Array.from({ length: 150 }, (_, i) => i)
const processFn = vi
.fn()
.mockResolvedValueOnce({ batch: 1 })
.mockResolvedValueOnce({ batch: 2 })

const result = await processBatches(items, processFn)

expect(result).toEqual([{ batch: 1 }, { batch: 2 }])
})

it('processes batches sequentially', async () => {
const items = Array.from({ length: 150 }, (_, i) => i)
const callOrder: number[] = []

const processFn = vi.fn().mockImplementation(async (batch: number[]) => {
const firstItem = batch[0]
if (firstItem !== undefined) {
callOrder.push(firstItem)
}
return `batch-${firstItem}`
})

await processBatches(items, processFn)

expect(callOrder).toEqual([0, 100])
})

it('uses custom batch size', async () => {
const items = Array.from({ length: 75 }, (_, i) => i)
const processFn = vi.fn().mockResolvedValue('result')

await processBatches(items, processFn, 25)

expect(processFn).toHaveBeenCalledTimes(3)
})

it('propagates errors from processing function', async () => {
const items = Array.from({ length: 150 }, (_, i) => i)
const processFn = vi.fn().mockRejectedValue(new Error('Processing failed'))

await expect(processBatches(items, processFn)).rejects.toThrow('Processing failed')
})

it('uses DEFAULT_BATCH_SIZE when batch size is not specified', async () => {
const items = Array.from({ length: 150 }, (_, i) => i)
const processFn = vi.fn().mockResolvedValue('result')

await processBatches(items, processFn)

expect(processFn).toHaveBeenCalledTimes(2)
const firstBatch = items.slice(0, DEFAULT_BATCH_SIZE)
expect(processFn).toHaveBeenNthCalledWith(1, expect.arrayContaining(firstBatch))
})
})
})
34 changes: 34 additions & 0 deletions src/helpers/batchProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const DEFAULT_BATCH_SIZE = 100

export function splitIntoBatches<T>(items: T[], batchSize: number = DEFAULT_BATCH_SIZE): T[][] {
if (batchSize <= 0) {
throw new Error('Batch size must be greater than 0')
}

if (items.length === 0) {
return []
}

const batches: T[][] = []
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize))
}

return batches
}

export async function processBatches<T, R>(
items: T[],
processFn: (batch: T[]) => Promise<R>,
batchSize: number = DEFAULT_BATCH_SIZE,
): Promise<R[]> {
const batches = splitIntoBatches(items, batchSize)
const results: R[] = []

for (const batch of batches) {
const result = await processFn(batch)
results.push(result)
}

return results
}
Loading