Skip to content
Draft
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
11 changes: 10 additions & 1 deletion docs/reference/endpoint-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,10 @@
"method": "PUT",
"path": "/v1/companies/:companyId/payrolls/:payrollId"
},
{
"method": "POST",
"path": "/v1/payrolls/:payrollUuid/gross_up"
},
{
"method": "GET",
"path": "/v1/companies/:companyUuid/payrolls/blockers"
Expand All @@ -840,7 +844,8 @@
"companyId",
"companyUuid",
"payScheduleId",
"payrollId"
"payrollId",
"payrollUuid"
]
},
"Payroll.PayrollEditEmployee": {
Expand Down Expand Up @@ -1651,6 +1656,10 @@
"method": "PUT",
"path": "/v1/companies/:companyId/payrolls/:payrollId"
},
{
"method": "POST",
"path": "/v1/payrolls/:payrollUuid/gross_up"
},
{
"method": "GET",
"path": "/v1/companies/:companyId/employees"
Expand Down
1 change: 1 addition & 0 deletions docs/reference/endpoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json'
| **Payroll.PayrollConfiguration** | GET | `/v1/companies/:companyId/payrolls/:payrollId` |
| | PUT | `/v1/companies/:companyId/payrolls/:payrollId/calculate` |
| | PUT | `/v1/companies/:companyId/payrolls/:payrollId` |
| | POST | `/v1/payrolls/:payrollUuid/gross_up` |
| | GET | `/v1/companies/:companyUuid/payrolls/blockers` |
| | GET | `/v1/companies/:companyId/employees` |
| | GET | `/v1/companies/:companyId/pay_schedules/:payScheduleId` |
Expand Down
8 changes: 8 additions & 0 deletions src/components/Common/UI/Menu/Menu.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,12 @@
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: toRem(20);
height: toRem(20);

svg {
width: toRem(20);
height: toRem(20);
}
}
2 changes: 1 addition & 1 deletion src/components/Common/UI/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function Menu(rawProps: MenuProps) {
onOpenChange={handleOpenChange}
isOpen={isOpen}
triggerRef={triggerRef}
placement="bottom start"
placement="bottom end"
offset={8}
shouldUpdatePosition={true}
>
Expand Down
5 changes: 5 additions & 0 deletions src/components/Common/UI/Table/Table.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@
}
&:last-child {
text-align: right;

:global(.react-aria-Button) {
display: inline-flex;
}
}

// Empty state row styling
Expand All @@ -133,6 +137,7 @@
padding: toRem(14) toRem(16);
transform: translateZ(0);
border-bottom: 1px solid var(--g-colorBorderSecondary);
vertical-align: middle;

&[role='rowheader'] {
color: var(--g-colorBodyContent);
Expand Down
29 changes: 29 additions & 0 deletions src/components/Payroll/GrossUpModal/GrossUpModal.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.content {
display: flex;
flex-direction: column;
gap: toRem(16);
}

.header {
display: flex;
flex-direction: column;
gap: toRem(8);
}

.inputSection {
max-width: toRem(320);
}

.alert {
margin-top: toRem(4);

> * {
margin-bottom: 0;
}
}

.result {
display: flex;
flex-direction: column;
gap: toRem(4);
}
74 changes: 74 additions & 0 deletions src/components/Payroll/GrossUpModal/GrossUpModal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Suspense, useState } from 'react'
import { GrossUpModal } from './GrossUpModal'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
import { useI18n } from '@/i18n'

function I18nLoader({ children }: { children: React.ReactNode }) {
useI18n('Payroll.GrossUpModal')
return <>{children}</>
}

export default {
title: 'Domain/Payroll/GrossUpModal',
decorators: [
(Story: React.ComponentType) => (
<Suspense fallback={<div>Loading translations...</div>}>
<I18nLoader>
<Story />
</I18nLoader>
</Suspense>
),
],
}

const mockCalculateGrossUp = async (netPay: number): Promise<string | null> => {
await new Promise(resolve => setTimeout(resolve, 500))
const grossUp = (netPay / 0.7).toFixed(2)
return grossUp
}

export const Default = () => {
const { Button } = useComponentContext()
const [isOpen, setIsOpen] = useState(false)

return (
<>
<Button
onClick={() => {
setIsOpen(true)
}}
>
Open Gross Up Modal
</Button>
<GrossUpModal
isOpen={isOpen}
onCalculateGrossUp={mockCalculateGrossUp}
isPending={false}
onApply={() => {
setIsOpen(false)
}}
onCancel={() => {
setIsOpen(false)
}}
/>
</>
)
}

export const OpenByDefault = () => {
const [isOpen, setIsOpen] = useState(true)

return (
<GrossUpModal
isOpen={isOpen}
onCalculateGrossUp={mockCalculateGrossUp}
isPending={false}
onApply={() => {
setIsOpen(false)
}}
onCancel={() => {
setIsOpen(false)
}}
/>
)
}
152 changes: 152 additions & 0 deletions src/components/Payroll/GrossUpModal/GrossUpModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { expect, describe, it, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { GrossUpModal } from './GrossUpModal'
import { renderWithProviders } from '@/test-utils/renderWithProviders'

const defaultProps = {
isOpen: true,
onCalculateGrossUp: vi.fn(),
isPending: false,
onApply: vi.fn(),
onCancel: vi.fn(),
}

describe('GrossUpModal', () => {
beforeEach(() => {
vi.clearAllMocks()
HTMLDialogElement.prototype.showModal = vi.fn()
HTMLDialogElement.prototype.close = vi.fn()
Object.defineProperty(HTMLDialogElement.prototype, 'open', {
get: vi.fn(() => false),
set: vi.fn(),
configurable: true,
})
})

it('renders modal with net pay input when open', async () => {
renderWithProviders(<GrossUpModal {...defaultProps} />)

await waitFor(() => {
expect(screen.getByText('Gross up calculator')).toBeInTheDocument()
})

expect(screen.getByText(/net amount you want this employee/)).toBeInTheDocument()
expect(screen.getByLabelText('Net amount')).toBeInTheDocument()
expect(screen.getByText('Calculate Gross from Net')).toBeInTheDocument()
expect(screen.getByText('Cancel')).toBeInTheDocument()
})

it('does not open the dialog when closed', () => {
const showModalSpy = vi.spyOn(HTMLDialogElement.prototype, 'showModal')
renderWithProviders(<GrossUpModal {...defaultProps} isOpen={false} />)

expect(showModalSpy).not.toHaveBeenCalled()
})

it('calls onCancel when Cancel button is clicked', async () => {
const user = userEvent.setup()
renderWithProviders(<GrossUpModal {...defaultProps} />)

await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument()
})

await user.click(screen.getByText('Cancel'))

expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})

it('calls onCalculateGrossUp and displays result on calculate', async () => {
const onCalculateGrossUp = vi.fn().mockResolvedValue('5000.00')
const user = userEvent.setup()
renderWithProviders(<GrossUpModal {...defaultProps} onCalculateGrossUp={onCalculateGrossUp} />)

await waitFor(() => {
expect(screen.getByLabelText('Net amount')).toBeInTheDocument()
})

const netPayInput = screen.getByLabelText('Net amount')
await user.clear(netPayInput)
await user.type(netPayInput, '3500')

await user.click(screen.getByText('Calculate Gross from Net'))

await waitFor(() => {
expect(screen.getByText('Calculated gross pay')).toBeInTheDocument()
expect(screen.getByText('$5,000.00')).toBeInTheDocument()
})

expect(onCalculateGrossUp).toHaveBeenCalledWith(3500)
expect(screen.getByText('Apply')).toBeInTheDocument()
})

it('calls onApply with gross amount when Apply is clicked', async () => {
const onCalculateGrossUp = vi.fn().mockResolvedValue('5000.00')
const user = userEvent.setup()
renderWithProviders(<GrossUpModal {...defaultProps} onCalculateGrossUp={onCalculateGrossUp} />)

await waitFor(() => {
expect(screen.getByLabelText('Net amount')).toBeInTheDocument()
})

const netPayInput = screen.getByLabelText('Net amount')
await user.clear(netPayInput)
await user.type(netPayInput, '3500')
await user.click(screen.getByText('Calculate Gross from Net'))

await waitFor(() => {
expect(screen.getByText('Apply')).toBeInTheDocument()
})

await user.click(screen.getByText('Apply'))

expect(defaultProps.onApply).toHaveBeenCalledWith(5000)
})

it('shows error message when onCalculateGrossUp returns null', async () => {
const onCalculateGrossUp = vi.fn().mockResolvedValue(null)
const user = userEvent.setup()
renderWithProviders(<GrossUpModal {...defaultProps} onCalculateGrossUp={onCalculateGrossUp} />)

await waitFor(() => {
expect(screen.getByLabelText('Net amount')).toBeInTheDocument()
})

const netPayInput = screen.getByLabelText('Net amount')
await user.clear(netPayInput)
await user.type(netPayInput, '3500')
await user.click(screen.getByText('Calculate Gross from Net'))

await waitFor(() => {
expect(
screen.getByText('Unable to calculate gross up. Please try again.'),
).toBeInTheDocument()
})
})

it('displays warning banner only after calculation', async () => {
const onCalculateGrossUp = vi.fn().mockResolvedValue('5000.00')
const user = userEvent.setup()
renderWithProviders(<GrossUpModal {...defaultProps} onCalculateGrossUp={onCalculateGrossUp} />)

await waitFor(() => {
expect(screen.getByLabelText('Net amount')).toBeInTheDocument()
})

expect(
screen.queryByText('This will override any previously entered amounts.'),
).not.toBeInTheDocument()

const netPayInput = screen.getByLabelText('Net amount')
await user.clear(netPayInput)
await user.type(netPayInput, '3500')
await user.click(screen.getByText('Calculate Gross from Net'))

await waitFor(() => {
expect(
screen.getByText('This will override any previously entered amounts.'),
).toBeInTheDocument()
})
})
})
Loading