diff --git a/.gitignore b/.gitignore index d7b61c3ae..e87b80e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ CLAUDE.local.md coverage example/public/ out -example/tsconfig.e2e.tsbuildinfo \ No newline at end of file +example/tsconfig.e2e.tsbuildinfo diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 99ea7fc77..e17ccbb72 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -1,4 +1,9 @@ -import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; +import { + queryOptions, + useMutation, + useQuery, + UseQueryResult, +} from '@tanstack/react-query'; import { FieldValues } from 'react-hook-form'; import { Client } from '@/src/client/client'; import { @@ -32,6 +37,8 @@ import { getV1OnboardingEmploymentsEmploymentIdPreOnboardingRequirements, getV1OnboardingEmploymentsEmploymentIdPreOnboardingDocumentsId, PostV1OnboardingEmploymentsEmploymentIdPreOnboardingRequirementsRequirementSlugDocumentsData, + CreateJobTitleEligibilityCheckParams, + postV2EmploymentsEmploymentIdJobTitleEligibilityCheck, } from '@/src/client'; import { useClient } from '@/src/context'; @@ -56,6 +63,11 @@ import { import { createHeadlessForm } from '@/src/common/createHeadlessForm'; import { countriesOptions } from '@/src/common/api/countries'; import { useMemo } from 'react'; +import { $TSFixMe } from '@/scripts/types'; +import { + extractFieldErrors, + MutationErrorStructure, +} from '@/src/lib/mutations'; export const useCompany = (companyId: string) => { const { client } = useClient(); @@ -861,3 +873,47 @@ export const useEmploymentAgreementPreview = ( select: (response) => response.data?.data.employment_agreement, }); }; + +export const jobTitleEligibilityCheckOptions = ( + client: Client, + employmentId: string, + params: CreateJobTitleEligibilityCheckParams, +) => + queryOptions({ + queryKey: ['job-title-eligibility-check', employmentId, params] as const, + retry: false, + staleTime: Infinity, + queryFn: async ({ signal }) => { + const response = + await postV2EmploymentsEmploymentIdJobTitleEligibilityCheck({ + client, + headers: { + Authorization: ``, + }, + body: params, + path: { + employment_id: employmentId, + }, + signal, + }); + + if (response.error || !response.data) { + const errorData = + (response.error as $TSFixMe)?.error || response.error || {}; + const error: MutationErrorStructure = { + error: new Error( + typeof errorData.message === 'string' + ? errorData.message + : 'Something went wrong. Please try again later.', + ), + rawError: response.error as Record, + normalizedErrors: errorData.errors || {}, + fieldErrors: extractFieldErrors(response.error), + response: response.response, + }; + throw error; + } + + return response.data.data.job_title_eligibility_check; + }, + }); diff --git a/src/flows/Onboarding/components/OnboardingForm.tsx b/src/flows/Onboarding/components/OnboardingForm.tsx index 6cb242cf4..c3bd5f222 100644 --- a/src/flows/Onboarding/components/OnboardingForm.tsx +++ b/src/flows/Onboarding/components/OnboardingForm.tsx @@ -102,6 +102,7 @@ export function OnboardingForm({ id={formId} onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 RemoteFlows__OnboardingForm' + onBlur={() => onboardingBag.checkJobTitleEligibility(form.getValues())} > ; @@ -576,6 +577,10 @@ export const useOnboarding = ({ Boolean(employmentId)), ); + const isJobTitleEligibilityEnabled = Boolean( + options?.features?.includes('job_title_eligibility'), + ); + const { data: basicInformationForm, isLoading: isLoadingBasicInformationForm, @@ -1360,6 +1365,21 @@ export const useOnboarding = ({ ], ); + const jobTitleEligibility = useJobTitleEligibilityCheck({ + enabled: isJobTitleEligibilityEnabled, + employmentId: internalEmploymentId, + currentStepName, + contractDetailsFields: stepFields.contract_details, + submittedJobTitle: employment?.basic_information?.job_title as + | string + | undefined, + fallbackJobTitle: basicInformationInitialValues.job_title as + | string + | undefined, + parseFormValues, + handleValidation, + }); + return { /** * Employment id passed useful to be used between components @@ -1440,6 +1460,15 @@ export const useOnboarding = ({ */ parseFormValues, + /** + * 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). + * @param values - Current form values + */ + checkJobTitleEligibility: jobTitleEligibility.check, + /** * 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 new file mode 100644 index 000000000..e2c96b63f --- /dev/null +++ b/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx @@ -0,0 +1,123 @@ +import { ValidationResult } from '@remoteoss/remote-json-schema-form-kit'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import equal from 'fast-deep-equal'; +import { useRef, useState } from 'react'; +import { FieldValues } from 'react-hook-form'; +import { CreateJobTitleEligibilityCheckParams } from '@/src/client'; +import { Client } from '@/src/client/client'; +import { useClient } from '@/src/context'; +import { jobTitleEligibilityCheckOptions } from '@/src/flows/Onboarding/api'; +import { + getJobTitleEligibilityParams, + StepKeys, +} from '@/src/flows/Onboarding/utils'; +import { JSFFields } from '@/src/types/remoteFlows'; + +/** + * Owns the state and query for the job title eligibility check. + */ +export const useJobTitleEligibilityState = ({ + employmentId, + enabled, + currentStepName, +}: { + employmentId: string | undefined; + enabled: boolean; + currentStepName: StepKeys; +}) => { + const { client } = useClient(); + const [params, setParamsState] = + useState(null); + const paramsRef = useRef(params); + + const setParams = (next: CreateJobTitleEligibilityCheckParams | null) => { + paramsRef.current = next; + setParamsState(next); + }; + + const getOptions = (checkParams: CreateJobTitleEligibilityCheckParams) => + jobTitleEligibilityCheckOptions( + client as Client, + employmentId as string, + checkParams, + ); + + const query = useQuery({ + ...getOptions(params ?? {}), + enabled: Boolean( + enabled && + employmentId && + currentStepName === 'contract_details' && + params, + ), + }); + + return { + paramsRef, + setParams, + getOptions, + query, + }; +}; + +export const useJobTitleEligibilityCheck = ({ + enabled, + employmentId, + currentStepName, + contractDetailsFields, + fallbackJobTitle, + submittedJobTitle, + parseFormValues, + handleValidation, +}: { + enabled: boolean; + employmentId: string | undefined; + currentStepName: StepKeys; + contractDetailsFields: JSFFields; + fallbackJobTitle: string | undefined; + parseFormValues: (values: FieldValues) => Promise>; + handleValidation: ( + values: FieldValues, + ) => Promise; + submittedJobTitle: string | undefined; +}) => { + const queryClient = useQueryClient(); + const { paramsRef, setParams, getOptions, query } = + useJobTitleEligibilityState({ + employmentId, + enabled, + currentStepName, + }); + + const jobTitle = submittedJobTitle ?? fallbackJobTitle; + + const check = async (values: FieldValues) => { + if (!enabled || !employmentId || currentStepName !== 'contract_details') { + return; + } + const validation = await handleValidation(values); + const parsedValues = await parseFormValues(values); + const nextParams = getJobTitleEligibilityParams( + contractDetailsFields, + parsedValues, + validation?.formErrors, + jobTitle, + ); + const paramsChanged = !equal(paramsRef.current, nextParams); + if (paramsChanged) { + setParams(nextParams); + } + if (nextParams && paramsChanged) { + await queryClient + .query(getOptions(nextParams)) + .catch(() => + console.error('Failed to fetch job title eligibility check'), + ); + } + }; + + return { + isFetching: query.isFetching, + check, + }; +}; diff --git a/src/flows/Onboarding/utils.ts b/src/flows/Onboarding/utils.ts index 5f9c54a64..99290ea2c 100644 --- a/src/flows/Onboarding/utils.ts +++ b/src/flows/Onboarding/utils.ts @@ -1,5 +1,7 @@ +import { CreateJobTitleEligibilityCheckParams } from '@/src/client'; import { Employment, OnboardingFlowProps } from '@/src/flows/Onboarding/types'; import { Step } from '@/src/flows/useStepState'; +import { JSFField, JSFFields } from '@/src/types/remoteFlows'; export type StepKeys = | 'select_country' @@ -222,3 +224,55 @@ export const getEngagementAgreementDetailsSchemaVersion = ( options?.jsonSchemaVersion?.engagement_agreement_details || DEFAULT_VERSION ); }; + +export const JOB_TITLE_ELIGIBILITY_SLUG_FIELD = + 'additional_job_title_eligibility_check_slug'; + +export const JOB_TITLE_ELIGIBILITY_RESULT_FIELD = + 'additional_job_title_eligibility_check_result'; + +const JOB_TITLE_ELIGIBILITY_PARAM_FIELDS = [ + 'role_description', + 'role_is_onsite', + 'role_requires_license', +]; + +const isFilled = (value: unknown) => + value !== undefined && value !== null && value !== ''; + +export const getJobTitleEligibilityParams = ( + fields: JSFFields, + values: Record, + formErrors?: Record | null, + jobTitle?: string, +): CreateJobTitleEligibilityCheckParams | null => { + const jsfFields = fields as JSFField[]; + if ( + !jsfFields.some((field) => field.name === JOB_TITLE_ELIGIBILITY_SLUG_FIELD) + ) { + return null; + } + + const paramFields = jsfFields.filter( + (field) => + JOB_TITLE_ELIGIBILITY_PARAM_FIELDS.includes(field.name) && + field.isVisible !== false, + ); + + const isComplete = + paramFields.length > 0 && + paramFields.every( + (field) => isFilled(values[field.name]) && !formErrors?.[field.name], + ); + + if (!isComplete) { + return null; + } + + return { + ...(isFilled(jobTitle) ? { job_title: jobTitle } : {}), + ...Object.fromEntries( + paramFields.map((field) => [field.name, values[field.name]]), + ), + } as CreateJobTitleEligibilityCheckParams; +};