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
20 changes: 19 additions & 1 deletion src/common/createHeadlessForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
createHeadlessForm as baseCreateHeadlessForm,
modify,
} from '@remoteoss/remote-json-schema-form-kit';
import { convertToCents } from '@/src/components/form/utils';
import { convertToCents, isNumericValue } from '@/src/components/form/utils';
import {
JSFModify,
JSONSchemaFormResultWithFieldsets,
Expand Down Expand Up @@ -60,6 +60,7 @@ export const createHeadlessForm = (
}

let moneyFieldsData: Record<string, number | null> = {};
let numberFieldsData: Record<string, number> = {};

if (fieldValues) {
const moneyFields = findFieldsByType(jsfSchema.properties || {}, 'money');
Expand All @@ -70,6 +71,22 @@ export const createHeadlessForm = (
},
{},
);

const numberFields = findFieldsByType(jsfSchema.properties || {}, 'number');
numberFieldsData = numberFields.reduce<Record<string, number>>(
(acc, field) => {
// Only unambiguous numbers are injected. Conditionals are evaluated
// against these values before any validation runs, so a lenient cast
// (Number(' ') === 0) would open branches the user never filled in.
// Empty and untouched fields are left out entirely rather than
// materialised as 0.
if (isNumericValue(fieldValues[field])) {
acc[field] = Number(fieldValues[field]);
}
return acc;
},
{},
);
}

/**
Expand All @@ -80,6 +97,7 @@ export const createHeadlessForm = (
JSON.stringify({
...fieldValues,
...moneyFieldsData,
...numberFieldsData,
}),
);

Expand Down
166 changes: 166 additions & 0 deletions src/common/tests/createHeadlessForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { createHeadlessForm } from '../createHeadlessForm';

const schemaWithNumberGuard = {
additionalProperties: false,
type: 'object',
'x-rmt-meta': {
jsfVersion: '1',
},
properties: {
clause_apply: {
title: 'Apply clause',
type: 'string',
oneOf: [
{ const: 'yes', title: 'Yes' },
{ const: 'no', title: 'No' },
],
'x-jsf-presentation': {
inputType: 'radio',
},
},
compensation_percentage: {
title: 'Compensation percentage',
type: 'number',
'x-jsf-presentation': {
inputType: 'number',
},
},
compensation_amount: {
title: 'Compensation amount',
type: 'integer',
'x-jsf-presentation': {
currency: 'EUR',
inputType: 'money',
},
},
},
required: ['clause_apply'],
allOf: [
{
if: {
properties: {
clause_apply: { const: 'yes' },
compensation_percentage: { type: 'number' },
},
required: ['clause_apply', 'compensation_percentage'],
},
then: {
required: ['compensation_amount'],
},
else: {
properties: {
compensation_amount: false,
},
},
},
],
};

// The kit only treats a schema as v1 when jsfVersion is exactly '1'; anything
// else goes through the v0 path, which casts values with yup before evaluating
// conditionals.
const schemaWithNumberGuardV0 = {
...schemaWithNumberGuard,
'x-rmt-meta': {
jsfVersion: '0',
},
};

function getField(form: ReturnType<typeof createHeadlessForm>, name: string) {
return form.fields?.find((field) => field.name === name);
}

describe('createHeadlessForm', () => {
describe('number field coercion', () => {
it('should reveal a conditional field when the number guard value is a string', () => {
const form = createHeadlessForm(schemaWithNumberGuard, {
clause_apply: 'yes',
compensation_percentage: '50',
});

expect(getField(form, 'compensation_amount')?.isVisible).toBe(true);
});

it('should reveal a conditional field when the number guard value is already a number', () => {
const form = createHeadlessForm(schemaWithNumberGuard, {
clause_apply: 'yes',
compensation_percentage: 50,
});

expect(getField(form, 'compensation_amount')?.isVisible).toBe(true);
});

it('should keep the conditional field hidden when the number field is empty', () => {
const form = createHeadlessForm(schemaWithNumberGuard, {
clause_apply: 'yes',
compensation_percentage: '',
});

expect(getField(form, 'compensation_amount')?.isVisible).toBe(false);
});

it('should keep the conditional field hidden when the number field is not numeric', () => {
const form = createHeadlessForm(schemaWithNumberGuard, {
clause_apply: 'yes',
compensation_percentage: 'abc',
});

expect(getField(form, 'compensation_amount')?.isVisible).toBe(false);
});

it('should keep the conditional field hidden when the number field only contains whitespace', () => {
const form = createHeadlessForm(schemaWithNumberGuard, {
clause_apply: 'yes',
compensation_percentage: ' ',
});

expect(getField(form, 'compensation_amount')?.isVisible).toBe(false);
});

it('should keep the conditional field hidden for values Number() would coerce to 0', () => {
const booleanForm = createHeadlessForm(schemaWithNumberGuard, {
clause_apply: 'yes',
compensation_percentage: true,
});
const arrayForm = createHeadlessForm(schemaWithNumberGuard, {
clause_apply: 'yes',
compensation_percentage: [],
});

expect(getField(booleanForm, 'compensation_amount')?.isVisible).toBe(
false,
);
expect(getField(arrayForm, 'compensation_amount')?.isVisible).toBe(false);
});

it('should keep the conditional field hidden when the clause does not apply', () => {
const form = createHeadlessForm(schemaWithNumberGuard, {
clause_apply: 'no',
compensation_percentage: '50',
});

expect(getField(form, 'compensation_amount')?.isVisible).toBe(false);
});

// Nothing in createHeadlessForm branches on jsfVersion, so the coercion also
// runs on v0 schemas. These lock in that it stays a no-op there.
it('should not change how v0 schemas evaluate the same guard', () => {
const numeric = createHeadlessForm(schemaWithNumberGuardV0, {
clause_apply: 'yes',
compensation_percentage: '50',
});
const empty = createHeadlessForm(schemaWithNumberGuardV0, {
clause_apply: 'yes',
compensation_percentage: '',
});
const invalid = createHeadlessForm(schemaWithNumberGuardV0, {
clause_apply: 'yes',
compensation_percentage: 'abc',
});

expect(getField(numeric, 'compensation_amount')?.isVisible).toBe(true);
expect(getField(empty, 'compensation_amount')?.isVisible).toBe(false);
expect(getField(invalid, 'compensation_amount')?.isVisible).toBe(false);
});
});
});
3 changes: 2 additions & 1 deletion src/components/form/JSONSchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ export const JSONSchemaFormFields = ({
<ForcedValueField
name={fieldProps.name as string}
description={fieldProps.description as string}
value={fieldProps.const as string}
value={fieldProps.const as string | number}
fieldType={fieldProps.type as string}
statement={fieldProps.statement as $TSFixMe}
label={fieldProps.label as string}
helpCenter={fieldProps.meta?.helpCenter}
Expand Down
1 change: 1 addition & 0 deletions src/components/form/fields/FieldSetField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ export function FieldSetField({
name={fieldKey}
description={fieldProps.description}
value={fieldProps.const}
fieldType={fieldProps.type}
statement={fieldProps.statement}
label={fieldProps.label}
helpCenter={fieldProps.meta?.helpCenter}
Expand Down
10 changes: 7 additions & 3 deletions src/components/form/fields/ForcedValueField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { useEffect } from 'react';
import { HelpCenterDataProps } from '@/src/types/fields';
import { BaseFormDescription as Description } from '@/src/components/ui/form';
import { HelpCenter } from '@/src/components/shared/zendesk-drawer/HelpCenter';
import { convertFromCents } from '@/src/components/form/utils';

export type ForcedValueFieldProps = {
name: string;
value: string;
value: string | number;
fieldType?: string;
description: string;
statement?: {
title?: string;
Expand All @@ -20,12 +22,14 @@ export type ForcedValueFieldProps = {
export function ForcedValueField({
name,
value,
fieldType,
description,
statement,
label,
helpCenter,
}: ForcedValueFieldProps) {
const { setValue } = useFormContext();
const forcedValue = fieldType === 'money' ? convertFromCents(value) : value;
const forcedValueDescription = statement?.description || description;

const forcedValueTitle = statement?.title
Expand All @@ -36,8 +40,8 @@ export function ForcedValueField({
const descriptionId = `forced-value-${name}-description`;

useEffect(() => {
setValue(name, value);
}, [name, value, setValue]);
setValue(name, forcedValue);
}, [name, forcedValue, setValue]);

const isHiddenValue = !forcedValueDescription && !statement?.title;

Expand Down
25 changes: 25 additions & 0 deletions src/components/form/fields/tests/ForcedValueField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,31 @@ describe('ForcedValueField Component', () => {
expect(mockSetValue).toHaveBeenCalledWith('testField', 'forced-value');
});

it('converts money consts from cents to units before setting the form value', () => {
const mockSetValue = vi.fn();

const TestComponent = () => {
const methods = {
...useForm(),
setValue: mockSetValue,
};
return (
<FormProvider {...methods}>
<ForcedValueField
{...defaultProps}
name='compensation_amount'
value={125000}
fieldType='money'
/>
</FormProvider>
);
};

render(<TestComponent />);

expect(mockSetValue).toHaveBeenCalledWith('compensation_amount', 1250);
});

it('still sets form value even when field is hidden (no description and no title)', () => {
const mockSetValue = vi.fn();

Expand Down
43 changes: 32 additions & 11 deletions src/components/form/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,37 @@
}, {});
}

/**
* Tells whether a value unambiguously represents a number.
*
* Stricter than `Number()`, which happily turns booleans, arrays and blank
* strings into 0. Use this whenever coercing silently would change behaviour
* without going through validation first.
*/
export function isNumericValue(value: $TSFixMe): boolean {
if (typeof value === 'number') {
return Number.isFinite(value);
}

if (typeof value !== 'string' || value.trim() === '') {
return false;
}

return Number.isFinite(Number(value));
}

export function castNumberValue(value: $TSFixMe) {
// this prevents values with letters such as "2r" from being considered valid
// if the input is invalid, number().cast will return NaN
const castValue = Number(value);

if (Number.isNaN(castValue)) {
return value;
}

return castValue;
}

export const fieldTypesTransformations: Record<string, $TSFixMe> = {
[supportedTypes.COUNTRIES]: {
/**
Expand Down Expand Up @@ -231,17 +262,7 @@
},
},
[supportedTypes.NUMBER]: {
transformValueToAPI: () => (value: string) => {
// this prevents values with letters such as "2r" from being considered valid
// if the input is invalid, number().cast will return NaN
const castValue = Number(value);

if (Number.isNaN(castValue)) {
return value;
}

return castValue;
},
transformValueToAPI: () => castNumberValue,
},
[supportedTypes.MONEY]: {
transformValueFromAPI: () => (value: string | number) =>
Expand Down Expand Up @@ -348,7 +369,7 @@
case supportedTypes.TEXTAREA:
case supportedTypes.TEXT:
// Attempt to remove null bytes from form values - https://gitlab.com/remote-com/employ-starbase/tracker/-/issues/10670
acc[field.name] = formValues[field.name].replace(/\0/g, '');

Check warning on line 372 in src/components/form/utils.ts

View workflow job for this annotation

GitHub Actions / Lint and Format

eslint(no-control-regex)

Unexpected control character
break;

case supportedTypes.GROUP_ARRAY: {
Expand Down
Loading