Skip to content

Commit 539c6b8

Browse files
authored
feat(contractor-onboarding) - highlight errors (#961)
* feat(contractor-onboarding) - highlight errors * format * add tests
1 parent d1e80cb commit 539c6b8

10 files changed

Lines changed: 168 additions & 9 deletions

src/flows/ContractorOnboarding/components/BasicInformationStep.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { NormalizedFieldError } from '@/src/lib/mutations';
55
import { useContractorOnboardingContext } from '@/src/flows/ContractorOnboarding/context';
66
import { ContractorOnboardingForm } from '@/src/flows/ContractorOnboarding/components/ContractorOnboardingForm';
77
import { handleStepError } from '@/src/lib/utils';
8+
import { UseFormReturn } from 'react-hook-form';
89

910
type BasicInformationStepProps = {
1011
/*
@@ -36,7 +37,10 @@ export function BasicInformationStep({
3637
}: BasicInformationStepProps) {
3738
const { contractorOnboardingBag } = useContractorOnboardingContext();
3839

39-
const handleSubmit = async (payload: $TSFixMe) => {
40+
const handleSubmit = async (
41+
payload: $TSFixMe,
42+
form: UseFormReturn<$TSFixMe>,
43+
) => {
4044
try {
4145
const parsedValues =
4246
await contractorOnboardingBag.parseFormValues(payload);
@@ -51,6 +55,7 @@ export function BasicInformationStep({
5155
const structuredError = handleStepError(
5256
error,
5357
contractorOnboardingBag.meta?.fields?.basic_information,
58+
form,
5459
);
5560
onError?.(structuredError);
5661
}

src/flows/ContractorOnboarding/components/ContractDetailsStep.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { StatementOfWorkDisclaimer } from '@/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer';
1010
import { isCMOrCMPlus } from '@/src/flows/ContractorOnboarding/utils';
1111
import { handleStepError } from '@/src/lib/utils';
12+
import { UseFormReturn } from 'react-hook-form';
1213

1314
type ContractDetailsStepProps = {
1415
/*
@@ -44,7 +45,10 @@ export function ContractDetailsStep({
4445
}: ContractDetailsStepProps) {
4546
const { contractorOnboardingBag } = useContractorOnboardingContext();
4647

47-
const handleSubmit = async (payload: $TSFixMe) => {
48+
const handleSubmit = async (
49+
payload: $TSFixMe,
50+
form: UseFormReturn<$TSFixMe>,
51+
) => {
4852
try {
4953
const parsedValues =
5054
await contractorOnboardingBag.parseFormValues(payload);
@@ -59,6 +63,7 @@ export function ContractDetailsStep({
5963
const structuredError = handleStepError(
6064
error,
6165
contractorOnboardingBag.meta?.fields?.contract_details,
66+
form,
6267
);
6368
onError?.(structuredError);
6469
}

src/flows/ContractorOnboarding/components/ContractPreviewStep.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ContractPreviewResponse,
88
} from '@/src/flows/ContractorOnboarding/types';
99
import { handleStepError } from '@/src/lib/utils';
10+
import { UseFormReturn } from 'react-hook-form';
1011

1112
type ContractPreviewStepProps = {
1213
/*
@@ -38,7 +39,10 @@ export function ContractPreviewStep({
3839
}: ContractPreviewStepProps) {
3940
const { contractorOnboardingBag } = useContractorOnboardingContext();
4041

41-
const handleSubmit = async (payload: $TSFixMe) => {
42+
const handleSubmit = async (
43+
payload: $TSFixMe,
44+
form: UseFormReturn<$TSFixMe>,
45+
) => {
4246
try {
4347
const parsedValues =
4448
await contractorOnboardingBag.parseFormValues(payload);
@@ -53,6 +57,7 @@ export function ContractPreviewStep({
5357
const structuredError = handleStepError(
5458
error,
5559
contractorOnboardingBag.meta?.fields?.contract_preview,
60+
form,
5661
);
5762
onError?.(structuredError);
5863
}

src/flows/ContractorOnboarding/components/ContractorOnboardingForm.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@/src/flows/ContractorOnboarding/types';
1313
import { normalizeFieldErrors } from '@/src/lib/mutations';
1414
import { useJSONSchemaForm } from '@/src/components/form/useJSONSchemaForm';
15+
import { UseFormReturn } from 'react-hook-form';
1516

1617
type ContractorOnboardingFormProps = {
1718
onSubmit: (
@@ -20,6 +21,7 @@ type ContractorOnboardingFormProps = {
2021
| PricingPlanFormPayload
2122
| ContractorOnboardingContractDetailsFormPayload
2223
| EligibilityQuestionnaireFormPayload,
24+
form: UseFormReturn<$TSFixMe>,
2325
) => Promise<void>;
2426
components?: Components;
2527
fields?: JSFFields;
@@ -87,7 +89,7 @@ export function ContractorOnboardingForm({
8789
});
8890
}
8991
} else {
90-
await onSubmit(values as $TSFixMe);
92+
await onSubmit(values as $TSFixMe, form);
9193
}
9294
};
9395

src/flows/ContractorOnboarding/components/EligibilityQuestionnaireStep.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
EligibilityQuestionnaireFormPayload,
88
EligibilityQuestionnaireResponse,
99
} from '@/src/flows/ContractorOnboarding/types';
10+
import { UseFormReturn } from 'react-hook-form';
1011

1112
type EligibilityQuestionnaireStepProps = {
1213
/*
@@ -40,7 +41,10 @@ export function EligibilityQuestionnaireStep({
4041
}: EligibilityQuestionnaireStepProps) {
4142
const { contractorOnboardingBag } = useContractorOnboardingContext();
4243

43-
const handleSubmit = async (payload: $TSFixMe) => {
44+
const handleSubmit = async (
45+
payload: $TSFixMe,
46+
form: UseFormReturn<$TSFixMe>,
47+
) => {
4448
try {
4549
const parsedValues =
4650
await contractorOnboardingBag.parseFormValues(payload);
@@ -58,6 +62,7 @@ export function EligibilityQuestionnaireStep({
5862
const structuredError = handleStepError(
5963
error,
6064
contractorOnboardingBag.meta?.fields?.eligibility_questionnaire,
65+
form,
6166
);
6267
onError?.(structuredError);
6368
}

src/flows/ContractorOnboarding/components/PricingPlan.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
PricingPlanResponse,
88
} from '@/src/flows/ContractorOnboarding/types';
99
import { handleStepError } from '@/src/lib/utils';
10+
import { UseFormReturn } from 'react-hook-form';
1011

1112
type PricingPlanStepProps = {
1213
/**
@@ -43,7 +44,10 @@ export function PricingPlanStep({
4344
}: PricingPlanStepProps) {
4445
const { contractorOnboardingBag } = useContractorOnboardingContext();
4546

46-
const handleSubmit = async (payload: $TSFixMe) => {
47+
const handleSubmit = async (
48+
payload: $TSFixMe,
49+
form: UseFormReturn<$TSFixMe>,
50+
) => {
4751
try {
4852
const parsedValues =
4953
await contractorOnboardingBag.parseFormValues(payload);
@@ -58,6 +62,7 @@ export function PricingPlanStep({
5862
const structuredError = handleStepError(
5963
error,
6064
contractorOnboardingBag.meta?.fields?.pricing_plan,
65+
form,
6166
);
6267
onError?.(structuredError);
6368
}

src/flows/ContractorOnboarding/components/SelectCountryStep.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { $TSFixMe } from '@/src/types/remoteFlows';
88
import { useContractorOnboardingContext } from '@/src/flows/ContractorOnboarding/context';
99
import { ContractorOnboardingForm } from '@/src/flows/ContractorOnboarding/components/ContractorOnboardingForm';
1010
import { handleStepError } from '@/src/lib/utils';
11+
import { UseFormReturn } from 'react-hook-form';
1112

1213
type SelectCountryStepProps = {
1314
/*
@@ -38,7 +39,10 @@ export function SelectCountryStep({
3839
onError,
3940
}: SelectCountryStepProps) {
4041
const { contractorOnboardingBag } = useContractorOnboardingContext();
41-
const handleSubmit = async (payload: $TSFixMe) => {
42+
const handleSubmit = async (
43+
payload: $TSFixMe,
44+
form: UseFormReturn<$TSFixMe>,
45+
) => {
4246
try {
4347
await onSubmit?.({ countryCode: payload.country });
4448
const response = await contractorOnboardingBag.onSubmit(payload);
@@ -51,8 +55,8 @@ export function SelectCountryStep({
5155
const structuredError = handleStepError(
5256
error,
5357
contractorOnboardingBag.meta?.fields?.select_country,
58+
form,
5459
);
55-
5660
onError?.(structuredError);
5761
}
5862
};

src/flows/ContractorOnboarding/tests/ContractorOnboarding.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2634,6 +2634,84 @@ describe('ContractorOnboardingFlow', () => {
26342634
});
26352635
});
26362636

2637+
describe('Contract Details 422 Errors', () => {
2638+
it('should highlight fields and show error message when contract details submission fails with 422', async () => {
2639+
const employmentId = generateUniqueEmploymentId();
2640+
2641+
// Mock the contract document creation to fail with 422
2642+
server.use(
2643+
http.post('*/v1/contractors/employments/*/contract-documents', () => {
2644+
return HttpResponse.json(
2645+
{
2646+
errors: {
2647+
'service_duration.expiration_date': [
2648+
'date must be after start date',
2649+
],
2650+
},
2651+
},
2652+
{ status: 422 },
2653+
);
2654+
}),
2655+
);
2656+
2657+
mockRender.mockImplementation(
2658+
createMockRenderImplementation(MultiStepFormWithoutCountry),
2659+
);
2660+
2661+
render(
2662+
<ContractorOnboardingFlow
2663+
employmentId={employmentId}
2664+
countryCode='PRT'
2665+
skipSteps={['select_country']}
2666+
{...defaultProps}
2667+
/>,
2668+
{ wrapper: TestProviders },
2669+
);
2670+
2671+
// Navigate to contract details step
2672+
await screen.findByText(/Step: Basic Information/i);
2673+
await waitForElementToBeRemoved(() => screen.getByTestId('spinner'));
2674+
2675+
await fillBasicInformation();
2676+
2677+
let nextButton = screen.getByText(/Next Step/i);
2678+
nextButton.click();
2679+
2680+
await screen.findByText(/Step: Pricing Plan/i);
2681+
2682+
await fillContractorSubscription();
2683+
2684+
nextButton = screen.getByText(/Next Step/i);
2685+
nextButton.click();
2686+
2687+
await screen.findByText(/Step: Contract Details/i);
2688+
2689+
// Fill contract details
2690+
await fillContractDetails();
2691+
2692+
nextButton = screen.getByText(/Next Step/i);
2693+
nextButton.click();
2694+
2695+
// Wait for the error callback
2696+
await waitFor(() => {
2697+
expect(mockOnError).toHaveBeenCalled();
2698+
});
2699+
2700+
// Verify we stay on the contract details step
2701+
await screen.findByText(/Step: Contract Details/i);
2702+
2703+
// Assert the field is highlighted (has aria-invalid attribute)
2704+
const serviceEndDateField = screen.getByTestId(
2705+
'service_duration.expiration_date',
2706+
);
2707+
expect(serviceEndDateField).toBeInTheDocument();
2708+
expect(serviceEndDateField).toHaveAttribute('aria-invalid', 'true');
2709+
expect(
2710+
screen.getByText(/date must be after start date/i),
2711+
).toBeInTheDocument();
2712+
});
2713+
});
2714+
26372715
describe('AI Validation Errors', () => {
26382716
it('should display AI validation warning statement when contract document creation fails with non-skippable error for COR', async () => {
26392717
const employmentId = generateUniqueEmploymentId();

src/lib/utils.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,18 @@ export function isStructuredError(err: unknown): err is {
285285
}
286286

287287
/**
288-
* Handles the error for a step
288+
* Handles the error for a step and optionally sets form field errors
289289
* @param err - The error
290290
* @param fieldsMeta - The fields metadata
291+
* @param form - Optional form instance to set field errors
291292
* @returns The structured error
292293
*/
293294
export function handleStepError(
294295
err: unknown,
295296
fieldsMeta?: NestedMeta,
297+
form?: {
298+
setError: (name: string, error: { type: string; message: string }) => void;
299+
},
296300
): {
297301
error: Error;
298302
rawError: Record<string, unknown>;
@@ -304,6 +308,12 @@ export function handleStepError(
304308
err.fieldErrors || [],
305309
fieldsMeta,
306310
);
311+
312+
// Automatically set form field errors if form is provided
313+
if (form) {
314+
setFormFieldErrors(form, normalizedFieldErrors);
315+
}
316+
307317
return {
308318
error: err.error,
309319
rawError: err.rawError,
@@ -351,3 +361,42 @@ export function getNestedValue<T = unknown>(
351361

352362
return (result === undefined ? defaultValue : result) as T | undefined;
353363
}
364+
365+
/**
366+
* Sets backend validation errors into react-hook-form state
367+
* Converts backend field paths to react-hook-form paths and sets errors
368+
*
369+
* @example
370+
* Backend: "provisional_start_date" → Form: "provisional_start_date"
371+
* Backend: "service_duration/expiration_date" → Form: "service_duration.expiration_date"
372+
* Backend: "benefits[0]/value" → Form: "benefits.0.value"
373+
*
374+
* @param form - The react-hook-form instance
375+
* @param fieldErrors - Array of normalized field errors from the backend
376+
*/
377+
export function setFormFieldErrors(
378+
form: {
379+
setError: (name: string, error: { type: string; message: string }) => void;
380+
},
381+
fieldErrors: NormalizedFieldError[],
382+
): void {
383+
fieldErrors.forEach(({ field, messages }) => {
384+
try {
385+
// Convert backend field path to react-hook-form path
386+
// "/" → "." for nested objects
387+
// "[index]" → ".index" for arrays
388+
const formFieldPath = field
389+
.replace(/\//g, '.')
390+
.replace(/\[(\d+)\]/g, '.$1');
391+
392+
form.setError(formFieldPath, {
393+
type: 'server',
394+
message: messages.join('. '),
395+
});
396+
} catch (error) {
397+
// Silently ignore if field doesn't exist in form
398+
// This can happen if backend returns errors for fields not in current step
399+
console.warn(`Could not set error for field: ${field}`, error);
400+
}
401+
});
402+
}

src/tests/defaultComponents.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const defaultComponents = {
1111
type='date'
1212
id={field.name}
1313
data-testid={field.name}
14+
aria-invalid={!!fieldState.error}
1415
value={field.value}
1516
onChange={(e) => {
1617
field?.onChange?.(e.target.value);

0 commit comments

Comments
 (0)