Skip to content
Closed
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
4 changes: 1 addition & 3 deletions src/common/createHeadlessForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ export const createHeadlessForm = (
fieldValues?: FieldValues,
options?: { jsfModify?: JSFModify; transformMoneyFields?: boolean },
): JSONSchemaFormResultWithFieldsets => {
const { transformMoneyFields } = options || {
transformMoneyFields: true,
};
const transformMoneyFields = options?.transformMoneyFields ?? true;
if (options && options.jsfModify) {
const { required, allOf, ...modifyConfig } = options.jsfModify;
// muteLogging: true suppresses the generic library log; we surface the
Expand Down
10 changes: 1 addition & 9 deletions src/common/invoice-schedules/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,7 @@ export const useGetCreateInvoiceScheduleSchema = ({
includeCustomDays,
});

// `transformMoneyFields` has to be explicit: `createHeadlessForm` only defaults it on
// when no options object is passed at all, so passing `jsfModify` alone silently opted
// out. Without it the item amounts stay in the money input's major units — a decimal as
// soon as an amount has cents — and never satisfy the `integer` the row-reveal
// conditional matches on, which froze the form at a single item for any such amount.
return createHeadlessForm(schema, fieldValues, {
jsfModify,
transformMoneyFields: true,
});
return createHeadlessForm(schema, fieldValues, { jsfModify });
}, [
enabled,
currencies,
Expand Down
230 changes: 230 additions & 0 deletions src/common/tests/moneyEngineContract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { FieldValues } from 'react-hook-form';
import { createHeadlessForm } from '@/src/common/createHeadlessForm';
import {
checkFieldHasForcedValue,
parseJSFToValidate,
parseSubmitValues,
} from '@/src/components/form/utils';
import { JSONSchemaFormResultWithFieldsets } from '@/src/flows/types';
import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures';
import { $TSFixMe } from '@/src/types/remoteFlows';

type Schema = Record<string, unknown>;

type FormSession = {
render: (values: FieldValues) => Promise<$TSFixMe[]>;
validate: (values: FieldValues) => Promise<{
fields: $TSFixMe[];
errors: Record<string, unknown> | undefined;
}>;
};

const rebuildSession =
(options?: Parameters<typeof createHeadlessForm>[2]) =>
(schema: Schema): FormSession => {
let form: JSONSchemaFormResultWithFieldsets;
return {
render: async (values) => {
form = createHeadlessForm(schema, values, options);
return form.fields;
},
validate: async (values) => {
const { formErrors } = form.handleValidation(
await parseJSFToValidate(values, form.fields),
);
return { fields: form.fields, errors: formErrors };
},
};
};

const buildOnceSession = (schema: Schema): FormSession => {
const form = createHeadlessForm(schema, {}, { transformMoneyFields: false });
const validate = async (values: FieldValues) => {
const { formErrors } = form.handleValidation(
await parseJSFToValidate(values, form.fields, {
isPartialValidation: true,
}),
);
return { fields: form.fields, errors: formErrors };
};
return {
render: async (values) => (await validate(values)).fields,
validate,
};
};

const ENGINES = [
{ engine: 'jsf v0', meta: { jsfOldVersion: true } },
{ engine: 'jsf v1', meta: { jsfVersion: '1' } },
{ engine: 'no meta', meta: undefined },
];

const STRATEGIES = [
{ strategy: 'rebuild without options', start: rebuildSession() },
{
strategy: 'rebuild with jsfModify',
start: rebuildSession({ jsfModify: {} }),
},
{ strategy: 'build once + handleValidation', start: buildOnceSession },
];

const forcedValues = (fields: $TSFixMe[]) =>
Object.fromEntries(
fields
.filter((field) => field.isVisible && checkFieldHasForcedValue(field))
.map((field) => [field.name, field.const]),
);

const fillForm = async (session: FormSession, typed: FieldValues) => {
let values = typed;
let rendered = await session.render(values);
for (let pass = 0; pass < 2; pass++) {
values = { ...typed, ...forcedValues(rendered) };
rendered = await session.render(values);
}
const renderedAllowance = rendered.find(
(field) => field.name === 'working_hours_exemption_allowance',
);
const shown = {
allowance: renderedAllowance?.const,
description: renderedAllowance?.statement?.description,
};
const { fields, errors } = await session.validate(values);
return {
shown,
errors,
submitted: await parseSubmitValues(values, fields),
};
};

const { 'x-rmt-meta': _meta, ...portugalSchema } =
contractDetailsSchemaV1Portugal.data;

const extendedHours = {
annual_gross_salary: 71703.77,
work_schedule: 'full_time',
working_hours_exemption: 'yes',
maximum_working_hours_regime: 'yes',
};

describe.each(ENGINES)('money contract on $engine', ({ meta }) => {
const schema = (): Schema =>
structuredClone(
meta ? { ...portugalSchema, 'x-rmt-meta': meta } : portugalSchema,
);

describe.each(STRATEGIES)('$strategy', ({ start }) => {
it.each([
{ regime: 'yes', cents: 81257, label: '812.57' },
{ regime: 'no', cents: 29548, label: '295.48' },
])(
'computes and submits the forced money allowance in cents (regime $regime)',
async ({ regime, cents, label }) => {
const { shown, submitted } = await fillForm(start(schema()), {
...extendedHours,
maximum_working_hours_regime: regime,
});

expect(shown.allowance).toBe(cents);
expect(shown.description).toContain(`additional ${label} EUR monthly`);
expect(submitted.working_hours_exemption_allowance).toBe(cents);
expect(submitted.annual_gross_salary).toBe(7170377);
},
);

it('computes the allowance from the forced 40 hours when none were typed', async () => {
const { shown, submitted } = await fillForm(
start(schema()),
extendedHours,
);

expect(submitted.work_hours_per_week).toBe(40);
expect(shown.allowance).toBe(81257);
expect(submitted.working_hours_exemption_allowance).toBe(81257);
});

it('computes the allowance from typed part-time hours', async () => {
const { submitted } = await fillForm(start(schema()), {
...extendedHours,
work_schedule: 'part_time',
work_hours_per_week: 20,
part_time_salary_confirmation: 'acknowledged',
});

expect(submitted.work_hours_per_week).toBe(20);
expect(submitted.working_hours_exemption_allowance).toBe(162515);
});

it('recomputes the allowance on the same form when the regime changes', async () => {
const session = start(schema());

const first = await fillForm(session, extendedHours);
const second = await fillForm(session, {
...extendedHours,
maximum_working_hours_regime: 'no',
});
const third = await fillForm(session, extendedHours);

expect([
first.submitted.working_hours_exemption_allowance,
second.submitted.working_hours_exemption_allowance,
third.submitted.working_hours_exemption_allowance,
]).toEqual([81257, 29548, 81257]);
});

it('drops the allowance once extended hours are turned off', async () => {
const session = start(schema());
await fillForm(session, extendedHours);

const { submitted } = await fillForm(session, {
...extendedHours,
working_hours_exemption: 'no',
});

expect(submitted).not.toHaveProperty('working_hours_exemption_allowance');
expect(submitted).not.toHaveProperty('maximum_working_hours_regime');
});

it.each([
{ salary: 12179.99, hasError: true },
{ salary: 12180, hasError: false },
])(
'validates the full-time minimum salary in cents ($salary)',
async ({ salary, hasError }) => {
const { errors } = await fillForm(start(schema()), {
...extendedHours,
annual_gross_salary: salary,
});

expect(Boolean(errors?.annual_gross_salary)).toBe(hasError);
},
);

it('submits a conditional nullable money field in cents, and leaves it out when empty', async () => {
const withBonus = { ...extendedHours, has_signing_bonus: 'yes' };

const filled = await fillForm(start(schema()), {
...withBonus,
signing_bonus_amount: 1000.5,
});
const empty = await fillForm(start(schema()), {
...withBonus,
signing_bonus_amount: '',
});

expect(filled.submitted.signing_bonus_amount).toBe(100050);
expect(empty.submitted).not.toHaveProperty('signing_bonus_amount');
});

it('submits checked acknowledgement checkboxes as their const', async () => {
const { submitted } = await fillForm(start(schema()), {
...extendedHours,
salary_installments_confirmation: true,
contract_duration_type: true,
});

expect(submitted.salary_installments_confirmation).toBe('acknowledged');
expect(submitted.contract_duration_type).toBe('indefinite');
});
});
});
88 changes: 88 additions & 0 deletions src/flows/Onboarding/tests/OnboardingFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,94 @@ describe('OnboardingFlow', () => {
await screen.findByText(/Step: Benefits/i);
});

it.each([
{
maximumWorkingHoursRegime: 'yes',
allowanceInCents: 81257,
allowanceLabel: '812.57',
},
{
maximumWorkingHoursRegime: 'no',
allowanceInCents: 29548,
allowanceLabel: '295.48',
},
])(
'should submit the computed extended work hours allowance in cents (maximum_working_hours_regime: $maximumWorkingHoursRegime)',
async ({ maximumWorkingHoursRegime, allowanceInCents, allowanceLabel }) => {
// Served without x-rmt-meta, like PRT contract details v3, so the kit evaluates it with
// the jsf v1 engine. The allowance is a forced money value computed from the salary.
const { 'x-rmt-meta': _meta, ...schemaWithoutMeta } =
contractDetailsSchemaV1Portugal.data;

server.use(
http.get('*/v1/countries/PRT/contract_details*', () => {
return HttpResponse.json({ data: schemaWithoutMeta });
}),
http.get('*/v1/employments/:id', ({ params }) => {
return HttpResponse.json({
...employmentDefaultResponse,
data: {
...employmentDefaultResponse.data,
employment: {
...employmentDefaultResponse.data.employment,
id: params.id,
contract_details: {
...employmentDefaultResponse.data.employment.contract_details,
annual_gross_salary: 7170377,
working_hours_exemption: 'yes',
maximum_working_hours_regime: maximumWorkingHoursRegime,
},
},
},
});
}),
);

render(
<OnboardingFlow
employmentId={generateUniqueEmploymentId()}
{...defaultProps}
/>,
{
wrapper: TestProviders,
},
);

await waitForElementToBeRemoved(() => screen.getByTestId('spinner'));

await fillCountry('PRT');

await waitFor(() => {
expect(screen.getByLabelText(/Personal email/i)).toBeInTheDocument();
});

screen.getByText(/Next Step/i).click();

await screen.findByText(/Step: Contract Details/i);

await screen.findByText(
new RegExp(`additional ${allowanceLabel} EUR monthly`, 'i'),
);

screen.getByText(/Next Step/i).click();

await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledTimes(3);
});

expect(mockOnSubmit.mock.calls[2][0]).toEqual(
expect.objectContaining({
annual_gross_salary: 7170377,
working_hours_exemption: 'yes',
maximum_working_hours_regime: maximumWorkingHoursRegime,
working_hours_exemption_allowance: allowanceInCents,
}),
);

await screen.findByText(/Step: Benefits/i);
},
);

it('should show validation errors when submitting benefits step without selecting any benefits', async () => {
server.use(
http.get('*/v1/employments/*/benefit-offers', () => {
Expand Down
Loading
Loading