Skip to content

Commit e6f7d7b

Browse files
krisxcrashjeffredodd
authored andcommitted
fix: block switching between unlimited and accrual-based time off policy types
When editing a complete time off policy, prevent changing between unlimited and accrual-based accrual types in the UI: - Complete unlimited policies: all accrual method radios are disabled - Complete accrual-based policies: the "Unlimited" radio is disabled, but switching between "Based on hours worked" and "Fixed amount per year" is still allowed - Incomplete policies (still in creation wizard): no restrictions This matches the behavior in gws-flows, where the assumption is that migrating settings between these fundamentally different policy types is too complex. Users should create a new policy instead.
1 parent 3acabc3 commit e6f7d7b

6 files changed

Lines changed: 108 additions & 5 deletions

File tree

src/components/TimeOff/TimeOffManagement/PolicyConfigurationForm/PolicyConfigurationForm.test.tsx

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -972,10 +972,11 @@ describe('PolicyConfigurationForm - edit mode (deriveFormDefaults)', () => {
972972
expect(screen.getByLabelText('Custom date')).toBeChecked()
973973
})
974974

975-
it('sends policyResetDate: null when switching from hourly to unlimited', async () => {
975+
it('allows switching from hourly to unlimited for incomplete policies', async () => {
976976
const user = userEvent.setup()
977977
renderEditComponent({
978978
name: 'Hourly Vacation',
979+
complete: false,
979980
accrualMethod: 'per_hour_paid',
980981
accrualRate: '1.5',
981982
accrualRateUnit: '40',
@@ -1003,10 +1004,94 @@ describe('PolicyConfigurationForm - edit mode (deriveFormDefaults)', () => {
10031004
policyResetDate: null,
10041005
accrualRate: null,
10051006
accrualRateUnit: null,
1006-
complete: true,
10071007
}),
10081008
},
10091009
})
10101010
})
10111011
})
1012+
1013+
describe('accrual method locking for complete policies', () => {
1014+
it('disables all accrual method radios for a complete unlimited policy', async () => {
1015+
renderEditComponent({
1016+
name: 'Unlimited PTO',
1017+
complete: true,
1018+
accrualMethod: 'unlimited',
1019+
})
1020+
1021+
await waitFor(() => {
1022+
expect(screen.getByDisplayValue('Unlimited PTO')).toBeInTheDocument()
1023+
})
1024+
1025+
expect(screen.getByLabelText('Unlimited')).toBeDisabled()
1026+
expect(screen.getByLabelText('Based on hours worked')).toBeDisabled()
1027+
expect(screen.getByLabelText('Fixed amount per year')).toBeDisabled()
1028+
expect(
1029+
screen.getByText(
1030+
'The accrual type cannot be changed for an existing policy. Create a new policy to use a different accrual type.',
1031+
),
1032+
).toBeInTheDocument()
1033+
})
1034+
1035+
it('disables only the unlimited radio for a complete accrual-based policy', async () => {
1036+
renderEditComponent({
1037+
name: 'Hourly Vacation',
1038+
complete: true,
1039+
accrualMethod: 'per_hour_paid',
1040+
accrualRate: '1.5',
1041+
accrualRateUnit: '40',
1042+
})
1043+
1044+
await waitFor(() => {
1045+
expect(screen.getByDisplayValue('Hourly Vacation')).toBeInTheDocument()
1046+
})
1047+
1048+
expect(screen.getByLabelText('Unlimited')).toBeDisabled()
1049+
expect(screen.getByLabelText('Based on hours worked')).toBeEnabled()
1050+
expect(screen.getByLabelText('Fixed amount per year')).toBeEnabled()
1051+
expect(
1052+
screen.getByText(
1053+
'The accrual type cannot be changed for an existing policy. Create a new policy to use a different accrual type.',
1054+
),
1055+
).toBeInTheDocument()
1056+
})
1057+
1058+
it('allows switching between accrual subtypes for a complete accrual-based policy', async () => {
1059+
const user = userEvent.setup()
1060+
renderEditComponent({
1061+
name: 'Hourly Vacation',
1062+
complete: true,
1063+
accrualMethod: 'per_hour_paid',
1064+
accrualRate: '1.5',
1065+
accrualRateUnit: '40',
1066+
})
1067+
1068+
await waitFor(() => {
1069+
expect(screen.getByDisplayValue('Hourly Vacation')).toBeInTheDocument()
1070+
})
1071+
1072+
await user.click(screen.getByLabelText('Fixed amount per year'))
1073+
1074+
await waitFor(() => {
1075+
expect(screen.getByLabelText('Fixed amount per year')).toBeChecked()
1076+
})
1077+
})
1078+
1079+
it('does not lock accrual method radios for an incomplete policy', async () => {
1080+
renderEditComponent({
1081+
name: 'Incomplete Policy',
1082+
complete: false,
1083+
accrualMethod: 'per_hour_paid',
1084+
accrualRate: '1',
1085+
accrualRateUnit: '40',
1086+
})
1087+
1088+
await waitFor(() => {
1089+
expect(screen.getByDisplayValue('Incomplete Policy')).toBeInTheDocument()
1090+
})
1091+
1092+
expect(screen.getByLabelText('Unlimited')).toBeEnabled()
1093+
expect(screen.getByLabelText('Based on hours worked')).toBeEnabled()
1094+
expect(screen.getByLabelText('Fixed amount per year')).toBeEnabled()
1095+
})
1096+
})
10121097
})

src/components/TimeOff/TimeOffManagement/PolicyConfigurationForm/PolicyConfigurationForm.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,6 @@ function buildUpdateRequestBody(
166166
accrualRate: null,
167167
accrualRateUnit: null,
168168
policyResetDate: null,
169-
complete: true,
170169
}
171170
}
172171

@@ -282,6 +281,12 @@ function EditRoot({ companyId, policyType, policyId, defaultValues }: EditRootPr
282281
const fetchedDefaults = deriveFormDefaults(policy)
283282
const mergedDefaults = { ...fetchedDefaults, ...defaultValues }
284283

284+
const lockedAccrualCategory = policy.complete
285+
? fetchedDefaults.accrualMethod === 'unlimited'
286+
? ('unlimited' as const)
287+
: ('accrual_based' as const)
288+
: undefined
289+
285290
const handleContinue = useCallback(
286291
async (data: PolicyConfigurationFormData) => {
287292
await baseSubmitHandler(data, async () => {
@@ -318,6 +323,7 @@ function EditRoot({ companyId, policyType, policyId, defaultValues }: EditRootPr
318323
defaultValues={mergedDefaults}
319324
editingPolicyName={policy.name}
320325
isPending={isPending}
326+
lockedAccrualCategory={lockedAccrualCategory}
321327
/>
322328
)
323329
}

src/components/TimeOff/TimeOffManagement/PolicyConfigurationForm/PolicyConfigurationFormPresentation.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function PolicyConfigurationFormPresentation({
3030
defaultValues,
3131
editingPolicyName,
3232
isPending = false,
33+
lockedAccrualCategory,
3334
}: PolicyConfigurationFormPresentationProps) {
3435
useI18n('Company.TimeOff.CreateTimeOffPolicy')
3536
const { t } = useTranslation('Company.TimeOff.CreateTimeOffPolicy')
@@ -117,9 +118,10 @@ export function PolicyConfigurationFormPresentation({
117118
value: 'unlimited' as AccrualMethod,
118119
label: t('policyDetails.unlimitedLabel'),
119120
description: t('policyDetails.unlimitedHint'),
121+
isDisabled: lockedAccrualCategory === 'accrual_based',
120122
},
121123
],
122-
[t],
124+
[t, lockedAccrualCategory],
123125
)
124126

125127
const accrualMethodFixedOptions = useMemo(
@@ -182,9 +184,14 @@ export function PolicyConfigurationFormPresentation({
182184
<RadioGroupField<AccrualMethod>
183185
name="accrualMethod"
184186
label={t('policyDetails.accrualMethodLabel')}
185-
description={t('policyDetails.accrualMethodHint')}
187+
description={
188+
lockedAccrualCategory
189+
? t('policyDetails.accrualMethodLockedHint')
190+
: t('policyDetails.accrualMethodHint')
191+
}
186192
options={accrualMethodOptions}
187193
isRequired
194+
isDisabled={lockedAccrualCategory === 'unlimited'}
188195
errorMessage={t('policyDetails.validations.accrualMethod')}
189196
/>
190197

src/components/TimeOff/TimeOffManagement/PolicyConfigurationForm/PolicyConfigurationFormTypes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ export interface PolicyConfigurationFormData {
1515
resetDay?: number
1616
}
1717

18+
export type LockedAccrualCategory = 'unlimited' | 'accrual_based'
19+
1820
export interface PolicyConfigurationFormPresentationProps {
1921
onContinue: (data: PolicyConfigurationFormData) => void
2022
onCancel: () => void
2123
defaultValues?: Partial<PolicyConfigurationFormData>
2224
editingPolicyName?: string
2325
isPending?: boolean
26+
lockedAccrualCategory?: LockedAccrualCategory
2427
}

src/i18n/en/Company.TimeOff.CreateTimeOffPolicy.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"policyNameLabel": "Policy name",
66
"accrualMethodLabel": "Accrual method",
77
"accrualMethodHint": "The rate at which employees will accrue time paid time off.",
8+
"accrualMethodLockedHint": "The accrual type cannot be changed for an existing policy. Create a new policy to use a different accrual type.",
89
"perHourPaidLabel": "Based on hours worked",
910
"perHourPaidHint": "Employees earn time off based on hours worked (e.g., 1 hour for every 40 hours worked).",
1011
"perYearLabel": "Fixed amount per year",

src/types/i18next.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ export interface CompanyTimeOffCreateTimeOffPolicy{
371371
"policyNameLabel":string;
372372
"accrualMethodLabel":string;
373373
"accrualMethodHint":string;
374+
"accrualMethodLockedHint":string;
374375
"perHourPaidLabel":string;
375376
"perHourPaidHint":string;
376377
"perYearLabel":string;

0 commit comments

Comments
 (0)