Skip to content
Open
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ CLAUDE.local.md
coverage
example/public/
out
example/tsconfig.e2e.tsbuildinfo
example/tsconfig.e2e.tsbuildinfo
58 changes: 57 additions & 1 deletion src/flows/Onboarding/api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,6 +37,8 @@ import {
getV1OnboardingEmploymentsEmploymentIdPreOnboardingRequirements,
getV1OnboardingEmploymentsEmploymentIdPreOnboardingDocumentsId,
PostV1OnboardingEmploymentsEmploymentIdPreOnboardingRequirementsRequirementSlugDocumentsData,
CreateJobTitleEligibilityCheckParams,
postV2EmploymentsEmploymentIdJobTitleEligibilityCheck,
} from '@/src/client';

import { useClient } from '@/src/context';
Expand All @@ -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();
Expand Down Expand Up @@ -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<string, unknown>,
normalizedErrors: errorData.errors || {},
fieldErrors: extractFieldErrors(response.error),
response: response.response,
};
throw error;
}

return response.data.data.job_title_eligibility_check;
},
});
1 change: 1 addition & 0 deletions src/flows/Onboarding/components/OnboardingForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export function OnboardingForm({
id={formId}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4 RemoteFlows__OnboardingForm'
onBlur={() => onboardingBag.checkJobTitleEligibility(form.getValues())}
>
<JSONSchemaFormFields
components={components}
Expand Down
29 changes: 29 additions & 0 deletions src/flows/Onboarding/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { useErrorReporting } from '@/src/components/error-handling/useErrorRepor
import { useEmploymentQuery } from '@/src/common/api/employment';
import { DailyScheduleContainer } from '@/src/flows/Onboarding/components/DailySchedule/DailyScheduleContainer';
import { DailySchedule } from '@/src/flows/Onboarding/components/DailySchedule/DailySchedule';
import { useJobTitleEligibilityCheck } from '@/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck';

type OnboardingHookProps = Omit<OnboardingFlowProps, 'render'>;

Expand Down Expand Up @@ -576,6 +577,10 @@ export const useOnboarding = ({
Boolean(employmentId)),
);

const isJobTitleEligibilityEnabled = Boolean(
options?.features?.includes('job_title_eligibility'),
);

const {
data: basicInformationForm,
isLoading: isLoadingBasicInformationForm,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx
Original file line number Diff line number Diff line change
@@ -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<CreateJobTitleEligibilityCheckParams | null>(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<Record<string, unknown>>;
handleValidation: (
values: FieldValues,
) => Promise<ValidationResult | null | undefined>;
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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (nextParams && paramsChanged) {
await queryClient
.query(getOptions(nextParams))
.catch(() =>
console.error('Failed to fetch job title eligibility check'),
Comment thread
cursor[bot] marked this conversation as resolved.
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed checks skip later retries

Medium Severity

setParams runs before the request finishes, and check() only calls queryClient.query when params changed. After a failed first attempt, later blurs with the same role values skip the request, so the server never records a check unless the user edits a role field again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 38681bb. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixing it in #1408

};

return {
isFetching: query.isFetching,
check,
};
};
Comment thread
gabrielseco marked this conversation as resolved.
54 changes: 54 additions & 0 deletions src/flows/Onboarding/utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, unknown>,
formErrors?: Record<string, unknown> | 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;
};
Loading