diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 70353d843..12e8f1ec4 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -59,6 +59,7 @@ import { getContractDetailsSchemaVersion, getBasicInformationSchemaVersion, getBenefitOffersSchemaVersion, + withJobTitleEligibilityRiskAcknowledgement, } from '@/src/flows/Onboarding/utils'; // oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; @@ -317,7 +318,8 @@ export const useLegacyContractDetailsSchema = ({ return response; }, enabled: options?.queryOptions?.enabled, - select: ({ data }) => data?.data || {}, + select: ({ data }) => + withJobTitleEligibilityRiskAcknowledgement(data?.data || {}), }); const { form } = useHeadlessForm({ @@ -385,7 +387,8 @@ export const useContractDetailsSchema = ({ return response; }, enabled: options?.queryOptions?.enabled, - select: ({ data }) => data?.data, + select: ({ data }) => + data?.data && withJobTitleEligibilityRiskAcknowledgement(data.data), }); const { form, handleValidation, parseFormValues } = useHeadlessForm({ diff --git a/src/flows/Onboarding/components/OnboardingForm.tsx b/src/flows/Onboarding/components/OnboardingForm.tsx index c3bd5f222..c93225689 100644 --- a/src/flows/Onboarding/components/OnboardingForm.tsx +++ b/src/flows/Onboarding/components/OnboardingForm.tsx @@ -2,6 +2,7 @@ import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; import { Form } from '@/src/components/ui/form'; import { useEffect } from 'react'; import { useOnboardingContext } from '@/src/flows/Onboarding/context'; +import { JOB_TITLE_ELIGIBILITY_RESULT_FIELD } from '@/src/flows/Onboarding/utils'; import { JSFFields } from '@/src/types/remoteFlows'; import { BasicInformationFormPayload, @@ -48,6 +49,15 @@ export function OnboardingForm({ // oxlint-disable-next-line react-hooks/exhaustive-deps }, []); + const syncJobTitleEligibility = async (values: Record) => { + const eligibilityValues = + await onboardingBag.checkJobTitleEligibility(values); + Object.entries(eligibilityValues ?? {}).forEach(([name, value]) => + form.setValue(name, value), + ); + return eligibilityValues; + }; + const handleSubmit = async ( values: Record, event?: React.BaseSyntheticEvent, @@ -92,7 +102,18 @@ export function OnboardingForm({ } } else { // Handle normal form submission - await onSubmit(values); + const eligibilityValues = await syncJobTitleEligibility(values); + const hasVerdictChanged = + eligibilityValues && + (values[JOB_TITLE_ELIGIBILITY_RESULT_FIELD] ?? null) !== + eligibilityValues[JOB_TITLE_ELIGIBILITY_RESULT_FIELD]; + if (hasVerdictChanged) { + const isValidWithNewVerdict = await form.trigger(); + if (!isValidWithNewVerdict) { + return; + } + } + await onSubmit({ ...values, ...eligibilityValues }); } }; @@ -102,7 +123,7 @@ export function OnboardingForm({ id={formId} onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 RemoteFlows__OnboardingForm' - onBlur={() => onboardingBag.checkJobTitleEligibility(form.getValues())} + onBlur={() => syncJobTitleEligibility(form.getValues())} > {children} diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index f84c90310..a588e364c 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -644,9 +644,6 @@ export const useOnboarding = ({ }, }, }, - employer_acknowledges_risk: { - presentation: { hidden: true }, - }, ...(isDailyScheduleEnabled ? { daily_schedule: { @@ -1417,12 +1414,18 @@ export const useOnboarding = ({ /** * Runs the job title eligibility check with the given contract details values when the * 'job_title_eligibility' feature is enabled, the current step is contract_details, and the - * role fields are filled. The prebuilt form calls it on blur; call it yourself from a custom - * UI to trigger the same check at another point (e.g. on step entry or before submitting). + * role fields are filled. The prebuilt form runs it on blur and again right before submitting, + * since a submit can happen without a blur (e.g. pressing Enter); a custom UI should do both. * @param values - Current form values */ checkJobTitleEligibility: jobTitleEligibility.check, + /** + * True while the job title eligibility request is in flight. The prebuilt submit button is + * disabled meanwhile; use it to do the same in a custom UI. + */ + isCheckingJobTitleEligibility: jobTitleEligibility.isFetching, + /** * Function to handle form submission * @param values - Form values to submit diff --git a/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx b/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx index eac4d3b98..be40c59a3 100644 --- a/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx +++ b/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx @@ -1,5 +1,9 @@ import { ValidationResult } from '@remoteoss/remote-json-schema-form-kit'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + CancelledError, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import equal from 'fast-deep-equal'; import { useRef, useState } from 'react'; import { FieldValues } from 'react-hook-form'; @@ -9,9 +13,16 @@ import { useClient } from '@/src/context'; import { jobTitleEligibilityCheckOptions } from '@/src/flows/Onboarding/api'; import { getJobTitleEligibilityParams, + getJobTitleEligibilityValues, + JOB_TITLE_ELIGIBILITY_SLUG_FIELD, StepKeys, } from '@/src/flows/Onboarding/utils'; -import { JSFFields } from '@/src/types/remoteFlows'; +import { JSFField, JSFFields } from '@/src/types/remoteFlows'; + +const CLEARED_JOB_TITLE_ELIGIBILITY_VALUES = getJobTitleEligibilityValues({ + check_id: null, + verdict: 'not_assessed', +}); /** * Owns the state and query for the job title eligibility check. @@ -92,7 +103,15 @@ export const useJobTitleEligibilityCheck = ({ const jobTitle = submittedJobTitle ?? fallbackJobTitle; const check = async (values: FieldValues) => { - if (!enabled || !employmentId || currentStepName !== 'contract_details') { + const hasSlugField = (contractDetailsFields as JSFField[]).some( + (field) => field.name === JOB_TITLE_ELIGIBILITY_SLUG_FIELD, + ); + if ( + !enabled || + !employmentId || + currentStepName !== 'contract_details' || + !hasSlugField + ) { return; } const validation = await handleValidation(values); @@ -106,17 +125,23 @@ export const useJobTitleEligibilityCheck = ({ if (!equal(paramsRef.current, nextParams)) { setParams(nextParams); } - if (nextParams) { - await queryClient - .query(getOptions(nextParams)) - .catch(() => - console.error('Failed to fetch job title eligibility check'), - ); + if (!nextParams) { + return CLEARED_JOB_TITLE_ELIGIBILITY_VALUES; + } + try { + const result = await queryClient.query(getOptions(nextParams)); + return getJobTitleEligibilityValues(result); + } catch (error) { + if (error instanceof CancelledError) { + return; + } + console.error('Failed to fetch job title eligibility check'); + return CLEARED_JOB_TITLE_ELIGIBILITY_VALUES; } }; return { - isFetching: query.isFetching, + isFetching: currentStepName === 'contract_details' && query.isFetching, check, }; }; diff --git a/src/flows/Onboarding/tests/OnboardingFlowJobTitleEligibility.test.tsx b/src/flows/Onboarding/tests/OnboardingFlowJobTitleEligibility.test.tsx index 95f3d3301..d9266b45d 100644 --- a/src/flows/Onboarding/tests/OnboardingFlowJobTitleEligibility.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingFlowJobTitleEligibility.test.tsx @@ -1,207 +1,682 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { OnboardingFlow } from '@/src/flows/Onboarding/OnboardingFlow'; import { + contractDetailsSchemaV0JobTitleEligibility, contractDetailsSchemaV1JobTitleEligibility, employmentDefaultResponse, } from '@/src/flows/Onboarding/tests/fixtures'; -import { OnboardingRenderProps } from '@/src/flows/Onboarding/types'; +import { + OnboardingFlowProps, + OnboardingRenderProps, +} from '@/src/flows/Onboarding/types'; import { fillRadio, queryClient, TestProviders } from '@/src/tests/testHelpers'; import { server } from '@/src/tests/server'; import { $TSFixMe } from '@/src/types/remoteFlows'; -describe('OnboardingFlow - job title eligibility check on blur', () => { - let latestOnboardingBag: $TSFixMe; - - const mockRender = vi.fn( - ({ onboardingBag, components }: OnboardingRenderProps) => { - latestOnboardingBag = onboardingBag; - const { ContractDetailsStep } = components; - - if (onboardingBag.stepState.currentStep.name !== 'contract_details') { - return null; - } - - return ; - }, - ); - - const jobTitleEligibilityCheckSpy = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - mockRender.mockReset(); - queryClient.clear(); - - server.use( - http.get('*/v1/employments/:id', ({ params }) => { - return HttpResponse.json({ - ...employmentDefaultResponse, - data: { - ...employmentDefaultResponse.data, - employment: { - ...employmentDefaultResponse.data.employment, - id: params?.id, - country: { - code: 'ITA', - name: 'Italy', - alpha_2_code: 'IT', - supported_json_schemas: ['employment_basic_information'], +const schemaPaths = [ + { + jsfVersion: 'v1', + countryCode: 'ITA', + countryName: 'Italy', + schema: contractDetailsSchemaV1JobTitleEligibility, + }, + { + jsfVersion: 'v0', + countryCode: 'PRT', + countryName: 'Portugal', + schema: contractDetailsSchemaV0JobTitleEligibility, + }, +]; + +describe.each(schemaPaths)( + 'OnboardingFlow - job title eligibility check on blur (jsf $jsfVersion)', + ({ countryCode, countryName, schema }) => { + let latestOnboardingBag: $TSFixMe; + + const mockRender = vi.fn( + ({ onboardingBag, components }: OnboardingRenderProps) => { + latestOnboardingBag = onboardingBag; + const { ContractDetailsStep, SubmitButton } = components; + + if (onboardingBag.stepState.currentStep.name !== 'contract_details') { + return null; + } + + return ( + <> + + Continue + + ); + }, + ); + + const jobTitleEligibilityCheckSpy = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockRender.mockReset(); + queryClient.clear(); + + server.use( + http.get('*/v1/employments/:id', ({ params }) => { + return HttpResponse.json({ + ...employmentDefaultResponse, + data: { + ...employmentDefaultResponse.data, + employment: { + ...employmentDefaultResponse.data.employment, + id: params?.id, + country: { + code: countryCode, + name: countryName, + alpha_2_code: countryCode.slice(0, 2), + supported_json_schemas: ['employment_basic_information'], + }, }, }, + }); + }), + http.get( + `*/v1/countries/${countryCode}/employment_basic_information*`, + () => { + return HttpResponse.json({ + data: { properties: { name: { type: 'string', title: 'Name' } } }, + }); }, - }); - }), - http.get('*/v1/countries/ITA/employment_basic_information*', () => { - return HttpResponse.json({ - data: { properties: { name: { type: 'string', title: 'Name' } } }, - }); - }), - http.get('*/v1/countries/ITA/contract_details*', () => { - return HttpResponse.json(contractDetailsSchemaV1JobTitleEligibility); - }), - http.post( - '*/v2/employments/:id/job-title-eligibility-check', - async ({ request }) => { - jobTitleEligibilityCheckSpy(await request.json()); + ), + http.get(`*/v1/countries/${countryCode}/contract_details*`, () => { + return HttpResponse.json(schema); + }), + http.post( + '*/v2/employments/:id/job-title-eligibility-check', + async ({ request }) => { + jobTitleEligibilityCheckSpy(await request.json()); + return HttpResponse.json({ + data: { + job_title_eligibility_check: { + check_id: 'job-title-eligibility-check-id', + verdict: 'eligible', + }, + }, + }); + }, + ), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const renderContractDetailsStep = async ({ + features = ['job_title_eligibility'], + }: { + features?: NonNullable['features']; + } = {}) => { + render( + , + { wrapper: TestProviders }, + ); + + await waitFor(() => expect(latestOnboardingBag.isLoading).toBe(false)); + + act(() => { + latestOnboardingBag.goTo('contract_details'); + }); + + await screen.findByLabelText(/Role description/i); + }; + + const fillRoleFields = async () => { + const user = userEvent.setup(); + const roleDescription = screen.getByLabelText(/Role description/i); + + await user.clear(roleDescription); + await user.type(roleDescription, 'Backend engineer responsibilities'); + await fillRadio('Will this role require working onsite', 'no'); + await fillRadio('Does this role require a professional license', 'no'); + // Blur the last field touched so the values captured on blur include + // all three role fields already committed. + await user.tab(); + }; + + it('keeps the risk acknowledgement hidden when the check is not enabled', async () => { + await renderContractDetailsStep({ features: [] }); + + await fillRoleFields(); + + expect(jobTitleEligibilityCheckSpy).not.toHaveBeenCalled(); + expect( + screen.queryByLabelText(/I acknowledge the risks/i), + ).not.toBeInTheDocument(); + }); + + it('calls the job title eligibility check once the role fields are filled and blurred', async () => { + await renderContractDetailsStep(); + + await fillRoleFields(); + + await waitFor(() => + expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1), + ); + expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledWith({ + job_title: 'pm', + role_description: 'Backend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + }); + }); + + it('does not call the check again when a later blur carries the same values', async () => { + await renderContractDetailsStep(); + + await fillRoleFields(); + await waitFor(() => + expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1), + ); + + const user = userEvent.setup(); + const roleDescription = screen.getByLabelText(/Role description/i); + await user.click(roleDescription); + await user.tab(); + + // user.tab() doesn't wait for checkJobTitleEligibility - the onBlur handler + // doesn't await it, so a duplicate request (if the dedup were ever broken) + // could still be in flight right now. We wait up to 200ms to give it a + // chance to show up before checking that it didn't. waitFor normally throws + // if its condition never comes true, but here that's the outcome we want, + // so we swallow it with .catch and let the assertion below do the real check. + await waitFor( + () => { + expect(jobTitleEligibilityCheckSpy.mock.calls.length).toBeGreaterThan( + 1, + ); + }, + { timeout: 200 }, + ).catch(() => undefined); + + expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1); + }); + + it('retries the check on a later blur when the previous request failed', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + let failing = true; + const successSpy = vi.fn(); + server.use( + http.post('*/v2/employments/:id/job-title-eligibility-check', () => { + if (failing) { + return HttpResponse.json( + { message: 'Internal error' }, + { status: 500 }, + ); + } + successSpy(); return HttpResponse.json({ data: { job_title_eligibility_check: { - slug: 'job-title-eligibility-check-slug', - result: 'yes', + check_id: 'job-title-eligibility-check-id', + verdict: 'eligible', }, }, }); - }, - ), - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - const renderContractDetailsStep = async () => { - render( - , - { wrapper: TestProviders }, - ); + }), + ); + + await renderContractDetailsStep(); + + await fillRoleFields(); + await waitFor(() => + expect(console.error).toHaveBeenCalledWith( + 'Failed to fetch job title eligibility check', + ), + ); + expect(successSpy).not.toHaveBeenCalled(); - await waitFor(() => expect(latestOnboardingBag.isLoading).toBe(false)); + failing = false; + const user = userEvent.setup(); + await user.click(screen.getByLabelText(/Role description/i)); + await user.tab(); - act(() => { - latestOnboardingBag.goTo('contract_details'); + await waitFor(() => expect(successSpy).toHaveBeenCalledTimes(1)); }); - await screen.findByLabelText(/Role description/i); - }; + it('disables the submit button while the check is running and re-enables it once it settles', async () => { + let resolveCheck: () => void = () => {}; + const checkReleased = new Promise((resolve) => { + resolveCheck = resolve; + }); + server.use( + http.post( + '*/v2/employments/:id/job-title-eligibility-check', + async () => { + await checkReleased; + return HttpResponse.json({ + data: { + job_title_eligibility_check: { + check_id: 'job-title-eligibility-check-id', + verdict: 'eligible', + }, + }, + }); + }, + ), + ); - const fillRoleFields = async () => { - const user = userEvent.setup(); - const roleDescription = screen.getByLabelText(/Role description/i); + await renderContractDetailsStep(); + const submitButton = screen.getByRole('button', { name: 'Continue' }); + expect(submitButton).toBeEnabled(); - await user.clear(roleDescription); - await user.type(roleDescription, 'Backend engineer responsibilities'); - await fillRadio('Will this role require working onsite', 'no'); - await fillRadio('Does this role require a professional license', 'no'); - // Blur the last field touched so the values captured on blur include - // all three role fields already committed. - await user.tab(); - }; + await fillRoleFields(); - it('calls the job title eligibility check once the role fields are filled and blurred', async () => { - await renderContractDetailsStep(); + await waitFor(() => expect(submitButton).toBeDisabled()); + expect(latestOnboardingBag.isCheckingJobTitleEligibility).toBe(true); - await fillRoleFields(); + resolveCheck(); - await waitFor(() => - expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1), - ); - expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledWith({ - job_title: 'pm', - role_description: 'Backend engineer responsibilities', - role_is_onsite: 'no', - role_requires_license: 'no', + await waitFor(() => expect(submitButton).toBeEnabled()); + expect(latestOnboardingBag.isCheckingJobTitleEligibility).toBe(false); + }); + + it('aborts the in-flight check when the values change, without logging an error for it', async () => { + const errorSpy = vi.spyOn(console, 'error'); + const requests: { + roleDescription: string; + signal: AbortSignal; + release: () => void; + }[] = []; + server.use( + http.post( + '*/v2/employments/:id/job-title-eligibility-check', + async ({ request }) => { + const body = (await request.json()) as { role_description: string }; + let release: () => void = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + requests.push({ + roleDescription: body.role_description, + signal: request.signal, + release, + }); + await released; + return HttpResponse.json({ + data: { + job_title_eligibility_check: { + check_id: 'job-title-eligibility-check-id', + verdict: 'eligible', + }, + }, + }); + }, + ), + ); + + await renderContractDetailsStep(); + const submitButton = screen.getByRole('button', { name: 'Continue' }); + + await fillRoleFields(); + await waitFor(() => expect(requests).toHaveLength(1)); + + const user = userEvent.setup(); + const roleDescription = screen.getByLabelText(/Role description/i); + await user.clear(roleDescription); + await user.type(roleDescription, 'Frontend engineer responsibilities'); + await user.tab(); + + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[0].signal.aborted).toBe(true); + expect(requests[1].roleDescription).toBe( + 'Frontend engineer responsibilities', + ); + expect(submitButton).toBeDisabled(); + + requests[1].release(); + + await waitFor(() => expect(submitButton).toBeEnabled()); + expect(errorSpy).not.toHaveBeenCalledWith( + 'Failed to fetch job title eligibility check', + ); }); - }); - it('does not call the check again when a later blur carries the same values', async () => { - await renderContractDetailsStep(); + it('stops reporting the check as running once the user leaves contract details', async () => { + server.use( + http.post( + '*/v2/employments/:id/job-title-eligibility-check', + () => new Promise(() => {}), + ), + ); - await fillRoleFields(); - await waitFor(() => - expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1), - ); + await renderContractDetailsStep(); + await fillRoleFields(); + + await waitFor(() => + expect(latestOnboardingBag.isCheckingJobTitleEligibility).toBe(true), + ); + + act(() => { + latestOnboardingBag.goTo('basic_information'); + }); + + await waitFor(() => + expect(latestOnboardingBag.stepState.currentStep.name).toBe( + 'basic_information', + ), + ); + expect(latestOnboardingBag.isCheckingJobTitleEligibility).toBe(false); + }); + + it('re-enables the submit button when the check fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + server.use( + http.post('*/v2/employments/:id/job-title-eligibility-check', () => + HttpResponse.json({ message: 'Internal error' }, { status: 500 }), + ), + ); + + await renderContractDetailsStep(); + const submitButton = screen.getByRole('button', { name: 'Continue' }); + + await fillRoleFields(); - const user = userEvent.setup(); - const roleDescription = screen.getByLabelText(/Role description/i); - await user.click(roleDescription); - await user.tab(); - - // user.tab() doesn't wait for checkJobTitleEligibility - the onBlur handler - // doesn't await it, so a duplicate request (if the dedup were ever broken) - // could still be in flight right now. We wait up to 200ms to give it a - // chance to show up before checking that it didn't. waitFor normally throws - // if its condition never comes true, but here that's the outcome we want, - // so we swallow it with .catch and let the assertion below do the real check. - await waitFor( - () => { - expect(jobTitleEligibilityCheckSpy.mock.calls.length).toBeGreaterThan( - 1, + await waitFor(() => + expect(console.error).toHaveBeenCalledWith( + 'Failed to fetch job title eligibility check', + ), + ); + await waitFor(() => expect(submitButton).toBeEnabled()); + expect(latestOnboardingBag.isCheckingJobTitleEligibility).toBe(false); + }); + + describe('feeding the check result into the contract details schema', () => { + const updateEmploymentSpy = vi.fn(); + const checkResponsesSent = vi.fn(); + + const mockCheckResponse = ( + checks: { check_id: string | null; verdict: string }[], + ) => { + let call = 0; + server.use( + http.post('*/v2/employments/:id/job-title-eligibility-check', () => { + const check = checks[Math.min(call, checks.length - 1)]; + call += 1; + checkResponsesSent(call); + return HttpResponse.json({ + data: { job_title_eligibility_check: check }, + }); + }), ); - }, - { timeout: 200 }, - ).catch(() => undefined); - - expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1); - }); - - it('retries the check on a later blur when the previous request failed', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - let failing = true; - const successSpy = vi.fn(); - server.use( - http.post('*/v2/employments/:id/job-title-eligibility-check', () => { - if (failing) { - return HttpResponse.json( - { message: 'Internal error' }, - { status: 500 }, - ); - } - successSpy(); - return HttpResponse.json({ - data: { - job_title_eligibility_check: { - slug: 'job-title-eligibility-check-slug', - result: 'yes', - }, + }; + + beforeEach(() => { + server.use( + http.patch('*/v1/employments/:id', async ({ request }) => { + updateEmploymentSpy(await request.json()); + return HttpResponse.json(employmentDefaultResponse); + }), + ); + }); + + const submitAfterCheck = async (checks = 1) => { + const submitButton = screen.getByRole('button', { name: 'Continue' }); + await waitFor(() => + expect(checkResponsesSent).toHaveBeenCalledTimes(checks), + ); + await waitFor(() => expect(submitButton).toBeEnabled()); + submitButton.click(); + }; + + it('reveals the risk acknowledgement on yes_with_ack and submits it with the check slug and result', async () => { + mockCheckResponse([ + { + check_id: 'check-id', + verdict: 'eligible_with_risk_acknowledgement', + }, + ]); + + await renderContractDetailsStep(); + expect( + screen.queryByLabelText(/I acknowledge the risks/i), + ).not.toBeInTheDocument(); + + await fillRoleFields(); + + const acknowledgement = await screen.findByLabelText( + /I acknowledge the risks/i, + ); + await userEvent.setup().click(acknowledgement); + await submitAfterCheck(); + + await waitFor(() => + expect(updateEmploymentSpy).toHaveBeenCalledTimes(1), + ); + expect(updateEmploymentSpy).toHaveBeenCalledWith({ + contract_details: { + role_description: 'Backend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + employer_acknowledges_risk: 'acknowledged', + additional_job_title_eligibility_check_slug: 'check-id', + additional_job_title_eligibility_check_result: 'yes_with_ack', }, + pricing_plan_details: { frequency: 'monthly' }, }); - }), - ); + }); - await renderContractDetailsStep(); + it('submits the check slug and result without asking for an acknowledgement when eligible', async () => { + mockCheckResponse([{ check_id: 'check-id', verdict: 'eligible' }]); - await fillRoleFields(); - await waitFor(() => - expect(console.error).toHaveBeenCalledWith( - 'Failed to fetch job title eligibility check', - ), - ); - expect(successSpy).not.toHaveBeenCalled(); + await renderContractDetailsStep(); + await fillRoleFields(); + await submitAfterCheck(); + + await waitFor(() => + expect(updateEmploymentSpy).toHaveBeenCalledTimes(1), + ); + expect( + screen.queryByLabelText(/I acknowledge the risks/i), + ).not.toBeInTheDocument(); + expect(updateEmploymentSpy).toHaveBeenCalledWith({ + contract_details: { + role_description: 'Backend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + additional_job_title_eligibility_check_slug: 'check-id', + additional_job_title_eligibility_check_result: 'yes', + }, + pricing_plan_details: { frequency: 'monthly' }, + }); + }); + + it('submits without a slug or result when the check was not assessed', async () => { + mockCheckResponse([{ check_id: null, verdict: 'not_assessed' }]); + + await renderContractDetailsStep(); + await fillRoleFields(); + await submitAfterCheck(); + + await waitFor(() => + expect(updateEmploymentSpy).toHaveBeenCalledTimes(1), + ); + expect(updateEmploymentSpy).toHaveBeenCalledWith({ + contract_details: { + role_description: 'Backend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + }, + pricing_plan_details: { frequency: 'monthly' }, + }); + }); + + const fillRoleFieldsWithoutBlur = async () => { + await fillRadio('Will this role require working onsite', 'no'); + await fillRadio('Does this role require a professional license', 'no'); + const roleDescription = screen.getByLabelText(/Role description/i); + const user = userEvent.setup(); + await user.clear(roleDescription); + await user.type(roleDescription, 'Backend engineer responsibilities'); + }; + + const submitWithoutBlur = () => { + fireEvent.submit( + document.querySelector('.RemoteFlows__OnboardingForm') as Element, + ); + }; + + it('runs the check on a submit that happens without a blur and submits its slug and result', async () => { + mockCheckResponse([ + { check_id: 'stale-check-id', verdict: 'eligible' }, + { check_id: 'check-id', verdict: 'eligible' }, + ]); - failing = false; - const user = userEvent.setup(); - await user.click(screen.getByLabelText(/Role description/i)); - await user.tab(); + await renderContractDetailsStep(); + await fillRoleFieldsWithoutBlur(); + await waitFor(() => + expect(checkResponsesSent).toHaveBeenCalledTimes(1), + ); + + submitWithoutBlur(); + + await waitFor(() => + expect(updateEmploymentSpy).toHaveBeenCalledTimes(1), + ); + expect(checkResponsesSent).toHaveBeenCalledTimes(2); + expect(updateEmploymentSpy).toHaveBeenCalledWith({ + contract_details: { + role_description: 'Backend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + additional_job_title_eligibility_check_slug: 'check-id', + additional_job_title_eligibility_check_result: 'yes', + }, + pricing_plan_details: { frequency: 'monthly' }, + }); + }); + + it('submits without a blur when the verdict changes to one that requires nothing new', async () => { + mockCheckResponse([ + { check_id: 'stale-check-id', verdict: 'eligible' }, + { check_id: 'check-id', verdict: 'needs_review' }, + ]); + + await renderContractDetailsStep(); + await fillRoleFieldsWithoutBlur(); + await waitFor(() => + expect(checkResponsesSent).toHaveBeenCalledTimes(1), + ); + + submitWithoutBlur(); + + await waitFor(() => + expect(updateEmploymentSpy).toHaveBeenCalledTimes(1), + ); + expect(updateEmploymentSpy).toHaveBeenCalledWith({ + contract_details: { + role_description: 'Backend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + additional_job_title_eligibility_check_slug: 'check-id', + additional_job_title_eligibility_check_result: 'maybe', + }, + pricing_plan_details: { frequency: 'monthly' }, + }); + }); + + it('holds a submit without a blur when the check now requires a risk acknowledgement', async () => { + mockCheckResponse([ + { check_id: 'stale-check-id', verdict: 'eligible' }, + { + check_id: 'check-id', + verdict: 'eligible_with_risk_acknowledgement', + }, + ]); + + await renderContractDetailsStep(); + await fillRoleFieldsWithoutBlur(); + await waitFor(() => + expect(checkResponsesSent).toHaveBeenCalledTimes(1), + ); - await waitFor(() => expect(successSpy).toHaveBeenCalledTimes(1)); - }); -}); + submitWithoutBlur(); + + const acknowledgement = await screen.findByLabelText( + /I acknowledge the risks/i, + ); + expect(updateEmploymentSpy).not.toHaveBeenCalled(); + + await userEvent.setup().click(acknowledgement); + await submitAfterCheck(2); + + await waitFor(() => + expect(updateEmploymentSpy).toHaveBeenCalledTimes(1), + ); + expect(updateEmploymentSpy).toHaveBeenCalledWith({ + contract_details: { + role_description: 'Backend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + employer_acknowledges_risk: 'acknowledged', + additional_job_title_eligibility_check_slug: 'check-id', + additional_job_title_eligibility_check_result: 'yes_with_ack', + }, + pricing_plan_details: { frequency: 'monthly' }, + }); + }); + + it('hides the risk acknowledgement again once the role changes and the new check is eligible', async () => { + mockCheckResponse([ + { + check_id: 'risky-check-id', + verdict: 'eligible_with_risk_acknowledgement', + }, + { check_id: 'eligible-check-id', verdict: 'eligible' }, + ]); + + await renderContractDetailsStep(); + await fillRoleFields(); + await screen.findByLabelText(/I acknowledge the risks/i); + + const user = userEvent.setup(); + const roleDescription = screen.getByLabelText(/Role description/i); + await user.clear(roleDescription); + await user.type(roleDescription, 'Frontend engineer responsibilities'); + await user.tab(); + + await waitFor(() => + expect( + screen.queryByLabelText(/I acknowledge the risks/i), + ).not.toBeInTheDocument(), + ); + await submitAfterCheck(2); + + await waitFor(() => + expect(updateEmploymentSpy).toHaveBeenCalledTimes(1), + ); + expect(updateEmploymentSpy).toHaveBeenCalledWith({ + contract_details: { + role_description: 'Frontend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + additional_job_title_eligibility_check_slug: 'eligible-check-id', + additional_job_title_eligibility_check_result: 'yes', + }, + pricing_plan_details: { frequency: 'monthly' }, + }); + }); + }); + }, +); diff --git a/src/flows/Onboarding/tests/OnboardingSubmit.test.tsx b/src/flows/Onboarding/tests/OnboardingSubmit.test.tsx index 533c69304..52973a31c 100644 --- a/src/flows/Onboarding/tests/OnboardingSubmit.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingSubmit.test.tsx @@ -26,7 +26,10 @@ describe('OnboardingSubmit Component', () => { vi.clearAllMocks(); (useOnboardingContext as $TSFixMe).mockReturnValue({ formId: mockFormId, - onboardingBag: { isSubmitting: false }, + onboardingBag: { + isSubmitting: false, + isCheckingJobTitleEligibility: false, + }, }); (useFormFields as $TSFixMe).mockReturnValue({ components: { @@ -238,4 +241,18 @@ describe('OnboardingSubmit Component', () => { expect(call.disabled).toBe(false); expect(call.onClick).toBe(mockOnClick); }); + + it('disables the button while the job title eligibility check is in flight', () => { + (useOnboardingContext as $TSFixMe).mockReturnValue({ + formId: mockFormId, + onboardingBag: { + isSubmitting: false, + isCheckingJobTitleEligibility: true, + }, + }); + + render(Submit); + + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + }); }); diff --git a/src/flows/Onboarding/tests/fixtures/contractDetails/v1-job-title-eligibility.ts b/src/flows/Onboarding/tests/fixtures/contractDetails/v1-job-title-eligibility.ts index f7e453fe8..ef98742c5 100644 --- a/src/flows/Onboarding/tests/fixtures/contractDetails/v1-job-title-eligibility.ts +++ b/src/flows/Onboarding/tests/fixtures/contractDetails/v1-job-title-eligibility.ts @@ -1,66 +1,105 @@ /** - * Trimmed down jsf v1 contract details schema exposing only the fields the - * job title eligibility check cares about: role_description, role_is_onsite, - * role_requires_license, and the additional_job_title_eligibility_check_slug - * field whose presence gates the check. + * Trimmed down contract details schema exposing only the Role Requirements + * fieldset, as the gateway serves it to API partners for DEU, ITA and PRT: the + * role fields sent to the job title eligibility check, the hidden slug field, + * and the risk acknowledgement. The server-owned check result field and the + * yes_with_ack conditional are withheld from the served schema, so the SDK has + * to add them. role_description drops its minLength to keep the tests short. + * Served as jsf v1 and as jsf v0. */ -export const contractDetailsSchemaV1JobTitleEligibility = { - data: { - additionalProperties: false, - properties: { - role_description: { - title: 'Role description', - type: 'string', - 'x-jsf-presentation': { - inputType: 'textarea', - }, +const jobTitleEligibilitySchema = { + additionalProperties: false, + properties: { + role_description: { + title: 'Role description', + type: 'string', + 'x-jsf-presentation': { + inputType: 'textarea', }, - role_is_onsite: { - oneOf: [ - { - const: 'yes', - title: 'Yes', - }, - { - const: 'no', - title: 'No', - }, - ], - title: 'Will this role require working onsite?', - type: 'string', - 'x-jsf-presentation': { - direction: 'row', - inputType: 'radio', + }, + role_is_onsite: { + oneOf: [ + { + const: 'yes', + title: 'Yes', }, - }, - role_requires_license: { - oneOf: [ - { - const: 'yes', - title: 'Yes', - }, - { - const: 'no', - title: 'No', - }, - ], - title: 'Does this role require a professional license?', - type: 'string', - 'x-jsf-presentation': { - direction: 'row', - inputType: 'radio', + { + const: 'no', + title: 'No', }, + ], + title: 'Will this role require working onsite?', + type: 'string', + 'x-jsf-presentation': { + direction: 'row', + inputType: 'radio', }, - additional_job_title_eligibility_check_slug: { - type: ['string', 'null'], - 'x-jsf-presentation': { - inputType: 'hidden', + }, + role_requires_license: { + oneOf: [ + { + const: 'yes', + title: 'Yes', + }, + { + const: 'no', + title: 'No', }, + ], + title: 'Does this role require a professional license?', + type: 'string', + 'x-jsf-presentation': { + direction: 'row', + inputType: 'radio', + }, + }, + employer_acknowledges_risk: { + const: 'acknowledged', + title: 'I acknowledge the risks and wish to proceed.', + type: 'string', + 'x-jsf-presentation': { + inputType: 'checkbox', }, }, - type: 'object', + additional_job_title_eligibility_check_slug: { + type: ['string', 'null'], + 'x-jsf-presentation': { + inputType: 'hidden', + }, + }, + }, + required: ['role_description'], + type: 'object', + 'x-jsf-order': [ + 'role_description', + 'role_is_onsite', + 'role_requires_license', + 'employer_acknowledges_risk', + 'additional_job_title_eligibility_check_slug', + ], + 'x-rmt-flatFieldsets': { + additional_job_title_eligibility_check: { + propertiesByName: [ + 'additional_job_title_eligibility_check_slug', + 'role_description', + 'role_is_onsite', + 'role_requires_license', + 'employer_acknowledges_risk', + ], + title: 'Role Requirements', + }, + }, +}; + +export const contractDetailsSchemaV1JobTitleEligibility = { + data: { + ...jobTitleEligibilitySchema, 'x-rmt-meta': { jsfVersion: '1', }, }, }; + +export const contractDetailsSchemaV0JobTitleEligibility = { + data: jobTitleEligibilitySchema, +}; diff --git a/src/flows/Onboarding/tests/utils.test.ts b/src/flows/Onboarding/tests/utils.test.ts index cbc358dc2..3d1258e7c 100644 --- a/src/flows/Onboarding/tests/utils.test.ts +++ b/src/flows/Onboarding/tests/utils.test.ts @@ -5,6 +5,7 @@ import { getBenefitOffersSchemaVersion, getContractDetailsSchemaVersion, getJobTitleEligibilityParams, + getJobTitleEligibilityValues, JOB_TITLE_ELIGIBILITY_SLUG_FIELD, usesJsfV1ContractDetails, } from '../utils'; @@ -355,3 +356,29 @@ describe('getJobTitleEligibilityParams', () => { expect(result).toEqual(filledValues); }); }); + +describe('getJobTitleEligibilityValues', () => { + it.each([ + ['eligible', 'yes'], + ['not_eligible', 'no'], + ['needs_review', 'maybe'], + ['eligible_with_risk_acknowledgement', 'yes_with_ack'], + ['not_assessed', null], + ] as const)('maps the %s verdict to the %s result', (verdict, result) => { + expect( + getJobTitleEligibilityValues({ check_id: 'check-id', verdict }), + ).toEqual({ + additional_job_title_eligibility_check_slug: 'check-id', + additional_job_title_eligibility_check_result: result, + }); + }); + + it('sets the slug to null when the check has no identifier', () => { + expect( + getJobTitleEligibilityValues({ check_id: null, verdict: 'eligible' }), + ).toEqual({ + additional_job_title_eligibility_check_slug: null, + additional_job_title_eligibility_check_result: 'yes', + }); + }); +}); diff --git a/src/flows/Onboarding/types.ts b/src/flows/Onboarding/types.ts index 5bf86dfb5..10f94b404 100644 --- a/src/flows/Onboarding/types.ts +++ b/src/flows/Onboarding/types.ts @@ -182,9 +182,7 @@ export type OnboardingFlowProps = { * `meta.helpCenter`, `descriptionSuffix`) instead of packing them into `description`. * Custom text components must render `fieldData.descriptionSuffix` to keep the conversion toggle. * - 'job_title_eligibility': Run the job title eligibility check on the contract details step once - * the role fields are filled. Its outcome fills `additional_job_title_eligibility_check_slug` and, - * when the schema declares it, `additional_job_title_eligibility_check_result`, so fields that - * depend on the verdict (such as `employer_acknowledges_risk`) react to it. + * the role fields are filled. */ features?: OnboardingFeatures[]; }; diff --git a/src/flows/Onboarding/utils.ts b/src/flows/Onboarding/utils.ts index 99290ea2c..28d70c103 100644 --- a/src/flows/Onboarding/utils.ts +++ b/src/flows/Onboarding/utils.ts @@ -1,4 +1,7 @@ -import { CreateJobTitleEligibilityCheckParams } from '@/src/client'; +import { + CreateJobTitleEligibilityCheckParams, + JobTitleEligibilityCheck, +} from '@/src/client'; import { Employment, OnboardingFlowProps } from '@/src/flows/Onboarding/types'; import { Step } from '@/src/flows/useStepState'; import { JSFField, JSFFields } from '@/src/types/remoteFlows'; @@ -231,6 +234,61 @@ export const JOB_TITLE_ELIGIBILITY_SLUG_FIELD = export const JOB_TITLE_ELIGIBILITY_RESULT_FIELD = 'additional_job_title_eligibility_check_result'; +const EMPLOYER_ACKNOWLEDGES_RISK_FIELD = 'employer_acknowledges_risk'; + +type ContractDetailsJsonSchema = { + properties?: Record; + allOf?: unknown[]; + 'x-jsf-order'?: string[]; +}; + +export const withJobTitleEligibilityRiskAcknowledgement = < + T extends ContractDetailsJsonSchema, +>( + schema: T, +): T => { + const properties = schema.properties ?? {}; + if ( + !properties[JOB_TITLE_ELIGIBILITY_SLUG_FIELD] || + !properties[EMPLOYER_ACKNOWLEDGES_RISK_FIELD] || + properties[JOB_TITLE_ELIGIBILITY_RESULT_FIELD] + ) { + return schema; + } + + return { + ...schema, + properties: { + ...properties, + [JOB_TITLE_ELIGIBILITY_RESULT_FIELD]: { + type: ['string', 'null'], + 'x-jsf-presentation': { inputType: 'hidden' }, + }, + }, + allOf: [ + ...(schema.allOf ?? []), + { + if: { + properties: { + [JOB_TITLE_ELIGIBILITY_RESULT_FIELD]: { const: 'yes_with_ack' }, + }, + required: [JOB_TITLE_ELIGIBILITY_RESULT_FIELD], + }, + then: { required: [EMPLOYER_ACKNOWLEDGES_RISK_FIELD] }, + else: { properties: { [EMPLOYER_ACKNOWLEDGES_RISK_FIELD]: false } }, + }, + ], + ...(schema['x-jsf-order'] + ? { + 'x-jsf-order': [ + ...schema['x-jsf-order'], + JOB_TITLE_ELIGIBILITY_RESULT_FIELD, + ], + } + : {}), + }; +}; + const JOB_TITLE_ELIGIBILITY_PARAM_FIELDS = [ 'role_description', 'role_is_onsite', @@ -276,3 +334,22 @@ export const getJobTitleEligibilityParams = ( ), } as CreateJobTitleEligibilityCheckParams; }; + +const JOB_TITLE_ELIGIBILITY_VERDICT_RESULTS: Record< + JobTitleEligibilityCheck['verdict'], + string | null +> = { + eligible: 'yes', + not_eligible: 'no', + needs_review: 'maybe', + eligible_with_risk_acknowledgement: 'yes_with_ack', + not_assessed: null, +}; + +export const getJobTitleEligibilityValues = ( + check: JobTitleEligibilityCheck, +) => ({ + [JOB_TITLE_ELIGIBILITY_SLUG_FIELD]: check.check_id ?? null, + [JOB_TITLE_ELIGIBILITY_RESULT_FIELD]: + JOB_TITLE_ELIGIBILITY_VERDICT_RESULTS[check.verdict] ?? null, +});