Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
130d062
test(onboarding): cover the job title eligibility blur dedup
gabrielseco Sep 25, 2026
836274c
fix(onboarding): give the dedup test time for a broken fetch to land
gabrielseco Sep 25, 2026
56b8bad
docs(onboarding): explain the inverted waitFor in the dedup test
gabrielseco Sep 25, 2026
c438891
docs(onboarding): reword the waitFor comment in plain language
gabrielseco Sep 25, 2026
e13fb13
test(onboarding): cover retrying the job title eligibility check afte…
gabrielseco Sep 28, 2026
57bd08a
fix(onboarding): retry job title eligibility check after a failed req…
gabrielseco Sep 28, 2026
1fb6fda
chore(example): rename vite config to .mts to silence native config l…
gabrielseco Sep 25, 2026
522c18f
fix(scripts): send checkbox const value, not [true], in seed-onboardi…
gabrielseco Sep 25, 2026
8f20c58
feat(scripts): seed onboarding directly against a gateway env (#1371)
gabrielseco Sep 25, 2026
603325a
1.57.0 (#1407)
gabrielseco Sep 25, 2026
9e454ff
docs: surface the inline-comment convention in CLAUDE.md (#1405)
gabrielseco Sep 28, 2026
908261f
docs: document import order convention (#1404)
gabrielseco Sep 28, 2026
3f0a584
chore(deps): update dependency @remoteoss/remote-json-schema-form-kit…
renovate[bot] Sep 28, 2026
30d7c12
fix(onboarding): block invite while employment is in job_title_review…
gabrielseco Sep 28, 2026
a238d4b
chore: ignore CLAUDE.local.md (#1400)
gabrielseco Sep 28, 2026
d85505d
chore(deps-dev): update dependency dotenv to v18.0.1 (#1410)
renovate[bot] Sep 28, 2026
b4c8db3
chore(deps-dev): update dependency tsx to v4.23.15 (#1411)
renovate[bot] Sep 28, 2026
ca023c9
Merge branch 'found-1869-hook-up-the-form-onblur-events-to-trigger-th…
gabrielseco Sep 28, 2026
5071433
Merge branch 'found-1869-hook-up-the-form-onblur-events-to-trigger-th…
gabrielseco Oct 6, 2026
1cca2cd
Merge branch 'found-1869-hook-up-the-form-onblur-events-to-trigger-th…
gabrielseco Oct 7, 2026
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
5 changes: 2 additions & 3 deletions src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,10 @@ export const useJobTitleEligibilityCheck = ({
validation?.formErrors,
jobTitle,
);
const paramsChanged = !equal(paramsRef.current, nextParams);
if (paramsChanged) {
if (!equal(paramsRef.current, nextParams)) {
setParams(nextParams);
}
if (nextParams && paramsChanged) {
if (nextParams) {
await queryClient
.query(getOptions(nextParams))
.catch(() =>
Expand Down
207 changes: 207 additions & 0 deletions src/flows/Onboarding/tests/OnboardingFlowJobTitleEligibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { act, 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 {
contractDetailsSchemaV1JobTitleEligibility,
employmentDefaultResponse,
} from '@/src/flows/Onboarding/tests/fixtures';
import { 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 <ContractDetailsStep />;
},
);

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'],
},
},
},
});
}),
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());
return HttpResponse.json({
data: {
job_title_eligibility_check: {
slug: 'job-title-eligibility-check-slug',
result: 'yes',
},
},
});
},
),
);
});

afterEach(() => {
vi.restoreAllMocks();
});

const renderContractDetailsStep = async () => {
render(
<OnboardingFlow
companyId='test-company-id'
employmentId='test-employment-id'
skipSteps={['select_country']}
options={{ features: ['job_title_eligibility'] }}
render={mockRender}
/>,
{ 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('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.
Comment thread
gabrielseco marked this conversation as resolved.
await waitFor(
() => {
expect(jobTitleEligibilityCheckSpy.mock.calls.length).toBeGreaterThan(
1,
);
},
{ timeout: 200 },
).catch(() => undefined);

expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1);
Comment thread
cursor[bot] marked this conversation as resolved.
});

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',
},
},
});
}),
);

await renderContractDetailsStep();

await fillRoleFields();
await waitFor(() =>
expect(console.error).toHaveBeenCalledWith(
'Failed to fetch job title eligibility check',
),
);
expect(successSpy).not.toHaveBeenCalled();

failing = false;
const user = userEvent.setup();
await user.click(screen.getByLabelText(/Role description/i));
await user.tab();

await waitFor(() => expect(successSpy).toHaveBeenCalledTimes(1));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './v1-southKorea';
export * from './v1-germany';
export * from './v1-france';
export * from './v1-italy-apl';
export * from './v1-job-title-eligibility';
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* 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.
*/
export const contractDetailsSchemaV1JobTitleEligibility = {
data: {
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_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',
},
},
additional_job_title_eligibility_check_slug: {
type: ['string', 'null'],
'x-jsf-presentation': {
inputType: 'hidden',
},
},
},
type: 'object',
'x-rmt-meta': {
jsfVersion: '1',
},
},
};
88 changes: 88 additions & 0 deletions src/flows/Onboarding/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { $TSFixMe } from '@/src/types/remoteFlows';
import {
buildSteps,
getBasicInformationSchemaVersion,
getBenefitOffersSchemaVersion,
getContractDetailsSchemaVersion,
getJobTitleEligibilityParams,
JOB_TITLE_ELIGIBILITY_SLUG_FIELD,
usesJsfV1ContractDetails,
} from '../utils';

Expand Down Expand Up @@ -267,3 +270,88 @@ describe('buildSteps', () => {
});
});
});

describe('getJobTitleEligibilityParams', () => {
const buildField = (name: string, isVisible = true): $TSFixMe => ({
name,
isVisible,
});

const roleFields = [
buildField(JOB_TITLE_ELIGIBILITY_SLUG_FIELD),
buildField('role_description'),
buildField('role_is_onsite'),
buildField('role_requires_license'),
];

const filledValues = {
role_description: 'Backend engineer',
role_is_onsite: 'no',
role_requires_license: 'no',
};

it('returns null when the slug field is not part of the schema', () => {
const fields = roleFields.filter(
(field) => field.name !== JOB_TITLE_ELIGIBILITY_SLUG_FIELD,
);

const result = getJobTitleEligibilityParams(fields, filledValues);

expect(result).toBeNull();
});

it('returns null when a role field is missing a value', () => {
const result = getJobTitleEligibilityParams(roleFields, {
...filledValues,
role_description: '',
});

expect(result).toBeNull();
});

it('returns null when a role field has a validation error', () => {
const result = getJobTitleEligibilityParams(roleFields, filledValues, {
role_description: 'Too short',
});

expect(result).toBeNull();
});

it('ignores a role field that is hidden by conditional logic', () => {
const fields = [
buildField(JOB_TITLE_ELIGIBILITY_SLUG_FIELD),
buildField('role_description'),
buildField('role_is_onsite', false),
buildField('role_requires_license'),
];

const result = getJobTitleEligibilityParams(fields, filledValues);

expect(result).toEqual({
role_description: 'Backend engineer',
role_requires_license: 'no',
});
});

it('returns the params with job_title when all role fields are filled and valid', () => {
const result = getJobTitleEligibilityParams(
roleFields,
filledValues,
null,
'Software Engineer',
);

expect(result).toEqual({
job_title: 'Software Engineer',
role_description: 'Backend engineer',
role_is_onsite: 'no',
role_requires_license: 'no',
});
});

it('omits job_title when none is available', () => {
const result = getJobTitleEligibilityParams(roleFields, filledValues);

expect(result).toEqual(filledValues);
});
});
Loading