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
3 changes: 3 additions & 0 deletions docs/reference/Translations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4482,6 +4482,9 @@ Translation keys for the `Payroll.PayrollEditEmployee` i18n namespace.
| <a id="property-payrollpayrolleditemployeesavereimbursementcta"></a> `saveReimbursementCta` | `"Save reimbursement"` |
| <a id="property-payrollpayrolleditemployeetimeoffbalance"></a> `timeOffBalance` | |
| `timeOffBalance.remaining` | `"{{balance}} remaining"` |
| <a id="property-payrollpayrolleditemployeetimeoffcolumns"></a> `timeOffColumns` | |
| `timeOffColumns.hours` | `"Hours"` |
| `timeOffColumns.type` | `"Type"` |
| <a id="property-payrollpayrolleditemployeetimeofftitle"></a> `timeOffTitle` | `"Time off"` |
| <a id="property-payrollpayrolleditemployeetimeofftitledismissal"></a> `timeOffTitleDismissal` | `"Time off hours used this pay period"` |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@
.grossPayLabel {
color: var(--g-colorBodySubContent);
}

.inputContainer {
max-width: toRem(160);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, describe, it, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import {
type Employee,
EmployeePaymentMethod1,
Expand Down Expand Up @@ -673,12 +673,12 @@ describe('PayrollEditEmployeePresentation', () => {
expect(screen.getByText('Unused time off payout')).toBeInTheDocument()
})

const payoutInputs = screen.getAllByRole('spinbutton', { name: /Vacation Hours|Sick Hours/ })
const vacationPayoutInput = payoutInputs.find(
input =>
input.closest('[class*="fieldGroup"]')?.querySelector('h4')?.textContent ===
'Unused time off payout',
)
const payoutBox = screen
.getAllByTestId('data-box')
.find(box => box.textContent.includes('Unused time off payout'))
const vacationPayoutInput = payoutBox
? within(payoutBox).getByRole('spinbutton', { name: /Vacation Hours/ })
: undefined

if (vacationPayoutInput) {
await user.clear(vacationPayoutInput)
Expand Down Expand Up @@ -725,12 +725,12 @@ describe('PayrollEditEmployeePresentation', () => {
expect(screen.getByText('Unused time off payout')).toBeInTheDocument()
})

const payoutInputs = screen.getAllByRole('spinbutton', { name: /Vacation Hours|Sick Hours/ })
const vacationPayoutInput = payoutInputs.find(
input =>
input.closest('[class*="fieldGroup"]')?.querySelector('h4')?.textContent ===
'Unused time off payout',
)
const payoutBox = screen
.getAllByTestId('data-box')
.find(box => box.textContent.includes('Unused time off payout'))
const vacationPayoutInput = payoutBox
? within(payoutBox).getByRole('spinbutton', { name: /Vacation Hours/ })
: undefined

if (vacationPayoutInput) {
await user.clear(vacationPayoutInput)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import styles from './PayrollEditEmployeePresentation.module.scss'
import { TimeOffField, PayoutTimeOffField } from './TimeOffField'
import { TimeOffField, PayoutTimeOffField, TimeOffTypeCell } from './TimeOffField'
import {
Flex,
Grid,
Expand Down Expand Up @@ -186,6 +186,39 @@ const buildCompensationFromFormData = (
return updatedCompensation
}

type TimeOffFieldComponent = typeof TimeOffField | typeof PayoutTimeOffField

interface TimeOffDataViewProps {
timeOffs: PayrollEmployeeCompensationsTypePaidTimeOff[]
employee: Employee
label: string
Field: TimeOffFieldComponent
}

function TimeOffDataView({ timeOffs, employee, label, Field }: TimeOffDataViewProps) {
const { t } = useTranslation('Payroll.PayrollEditEmployee')

const dataViewProps = useDataView<PayrollEmployeeCompensationsTypePaidTimeOff>({
data: timeOffs,
columns: [
{
title: t('timeOffColumns.type'),
render: row => <TimeOffTypeCell timeOff={row} employee={employee} />,
},
{
title: t('timeOffColumns.hours'),
justify: 'end',
render: row => (
<div className={styles.inputContainer}>
<Field timeOff={row} shouldVisuallyHideLabel />
</div>
),
},
],
})
return <DataView label={label} isWithinBox {...dataViewProps} />
}

/** @internal */
export const PayrollEditEmployeePresentation = ({
onSave,
Expand All @@ -200,7 +233,7 @@ export const PayrollEditEmployeePresentation = ({
withReimbursements = true,
hasDirectDepositSetup = true,
}: PayrollEditEmployeeProps) => {
const { Button, ButtonIcon, Heading, Text, TextInput } = useComponentContext()
const { Box, BoxHeader, Button, ButtonIcon, Heading, Text, TextInput } = useComponentContext()

const { t } = useTranslation('Payroll.PayrollEditEmployee')
useI18n('Payroll.PayrollEditEmployee')
Expand Down Expand Up @@ -647,37 +680,45 @@ export const PayrollEditEmployeePresentation = ({
)}
{timeOff.length > 0 && (
<div className={styles.fieldGroup}>
<Heading as="h4">
{payrollCategory === PayrollCategory.Dismissal
? t('timeOffTitleDismissal')
: t('timeOffTitle')}
</Heading>
<Grid gridTemplateColumns={{ base: '1fr', small: [320, 320] }} gap={20}>
{timeOff.map(timeOffEntry => (
<TimeOffField
key={timeOffEntry.name}
timeOff={timeOffEntry}
employee={employee}
<Box
header={
<BoxHeader
title={
payrollCategory === PayrollCategory.Dismissal
? t('timeOffTitleDismissal')
: t('timeOffTitle')
}
/>
))}
</Grid>
}
withPadding={false}
>
<TimeOffDataView
timeOffs={timeOff}
employee={employee}
label={t('timeOffTitle')}
Field={TimeOffField}
/>
</Box>
</div>
)}
{payrollCategory === PayrollCategory.Dismissal && timeOff.length > 0 && (
<div className={styles.fieldGroup}>
<Flex flexDirection="column" gap={4}>
<Heading as="h4">{t('finalPayoutTitle')}</Heading>
<Text variant="supporting">{t('finalPayoutDescription')}</Text>
</Flex>
<Grid gridTemplateColumns={{ base: '1fr', small: [320, 320] }} gap={20}>
{timeOff.map(timeOffEntry => (
<PayoutTimeOffField
key={`payout-${timeOffEntry.name}`}
timeOff={timeOffEntry}
employee={employee}
<Box
header={
<BoxHeader
title={t('finalPayoutTitle')}
description={t('finalPayoutDescription')}
/>
))}
</Grid>
}
withPadding={false}
>
<TimeOffDataView
timeOffs={timeOff}
employee={employee}
label={t('finalPayoutTitle')}
Field={PayoutTimeOffField}
/>
</Box>
</div>
)}
{additionalEarnings.length > 0 && (
Expand Down
80 changes: 60 additions & 20 deletions src/components/Payroll/PayrollEditEmployee/TimeOffField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,35 @@ import {
EmployeePaymentMethod1,
} from '@gusto/embedded-api/models/components/employee'
import type { PayrollEmployeeCompensationsTypePaidTimeOff } from '@gusto/embedded-api/models/components/payrollemployeecompensationstype'
import { TimeOffField } from './TimeOffField'
import { TimeOffField, TimeOffTypeCell } from './TimeOffField'
import type { PayrollEditEmployeeFormValues } from './PayrollEditEmployeePresentation'
import { renderWithProviders } from '@/test-utils/renderWithProviders'

const TestWrapper = ({
const TestFieldWrapper = ({
timeOffEntry,
shouldVisuallyHideLabel,
}: {
timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff
shouldVisuallyHideLabel?: boolean
}) => {
const methods = useForm<PayrollEditEmployeeFormValues>({
defaultValues: {
timeOffCompensations: {
[timeOffEntry.name || '']: timeOffEntry.hours || '0',
},
},
})

return (
<FormProvider {...methods}>
<Suspense fallback={<div>Loading...</div>}>
<TimeOffField timeOff={timeOffEntry} shouldVisuallyHideLabel={shouldVisuallyHideLabel} />
</Suspense>
</FormProvider>
)
}

const TestTypeCellWrapper = ({
timeOffEntry,
employee,
}: {
Expand All @@ -29,7 +53,7 @@ const TestWrapper = ({
return (
<FormProvider {...methods}>
<Suspense fallback={<div>Loading...</div>}>
<TimeOffField timeOff={timeOffEntry} employee={employee} />
<TimeOffTypeCell timeOff={timeOffEntry} employee={employee} />
</Suspense>
</FormProvider>
)
Expand Down Expand Up @@ -77,47 +101,63 @@ describe('TimeOffField', () => {
hours: '8.0',
}

renderWithProviders(<TestWrapper timeOffEntry={timeOffEntry} employee={mockEmployee} />)
renderWithProviders(<TestFieldWrapper timeOffEntry={timeOffEntry} />)

await waitFor(() => {
expect(screen.getByLabelText('Vacation Hours')).toBeInTheDocument()
})
expect(screen.getByDisplayValue('8.0')).toBeInTheDocument()
})

it('should show remaining balance for accrual policies', async () => {
it('should keep an accessible label when visually hidden', async () => {
const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = {
name: 'Vacation Hours',
hours: '8.0',
}

renderWithProviders(<TestWrapper timeOffEntry={timeOffEntry} employee={mockEmployee} />)
renderWithProviders(<TestFieldWrapper timeOffEntry={timeOffEntry} shouldVisuallyHideLabel />)

await waitFor(() => {
expect(screen.getByText(/32\.0.*remaining/)).toBeInTheDocument()
expect(screen.getByLabelText('Vacation Hours')).toBeInTheDocument()
})
})

it('should not show balance for unlimited policies', async () => {
it('should not render if timeOffEntry has no name', () => {
const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = { hours: '8.0' }

renderWithProviders(<TestFieldWrapper timeOffEntry={timeOffEntry} />)

expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})

describe('TimeOffTypeCell', () => {
it('should render the time off name and remaining balance for accrual policies', async () => {
const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = {
name: 'Sick Hours',
hours: '0.0',
name: 'Vacation Hours',
hours: '8.0',
}

renderWithProviders(<TestWrapper timeOffEntry={timeOffEntry} employee={mockEmployee} />)
renderWithProviders(<TestTypeCellWrapper timeOffEntry={timeOffEntry} employee={mockEmployee} />)

await waitFor(() => {
expect(screen.getByLabelText('Sick Hours')).toBeInTheDocument()
expect(screen.getByText('Vacation Hours')).toBeInTheDocument()
})
expect(screen.queryByText('remaining')).not.toBeInTheDocument()
expect(screen.getByText(/32\.0.*remaining/)).toBeInTheDocument()
})

it('should not render if timeOffEntry has no name', () => {
const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = { hours: '8.0' }
it('should not show balance for unlimited policies', async () => {
const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = {
name: 'Sick Hours',
hours: '0.0',
}

renderWithProviders(<TestWrapper timeOffEntry={timeOffEntry} employee={mockEmployee} />)
renderWithProviders(<TestTypeCellWrapper timeOffEntry={timeOffEntry} employee={mockEmployee} />)

expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Sick Hours')).toBeInTheDocument()
})
expect(screen.queryByText(/remaining/)).not.toBeInTheDocument()
})

it('should not show balance if no matching policy found', async () => {
Expand All @@ -126,11 +166,11 @@ describe('TimeOffField', () => {
hours: '8.0',
}

renderWithProviders(<TestWrapper timeOffEntry={timeOffEntry} employee={mockEmployee} />)
renderWithProviders(<TestTypeCellWrapper timeOffEntry={timeOffEntry} employee={mockEmployee} />)

await waitFor(() => {
expect(screen.getByLabelText('Unknown Policy')).toBeInTheDocument()
expect(screen.getByText('Unknown Policy')).toBeInTheDocument()
})
expect(screen.queryByText('remaining')).not.toBeInTheDocument()
expect(screen.queryByText(/remaining/)).not.toBeInTheDocument()
})
})
Loading
Loading