Skip to content

Commit

Permalink
fix(select/radio): Allow numbers in oneOf (#49)
Browse files Browse the repository at this point in the history
* fix: allow number select

* chore: use mixed over number

* Fix: check for null in field's jsonType

* fix: validate number options

* chore: use number strict over mixed

* Release 0.9.1-dev.20240320153711

* fix: support null value

* Release 0.9.1-dev.20240321210726

* Revert "Release 0.9.1-dev.20240321210726"

---------

Co-authored-by: StemCll <lydjotj6f@mozmail.com>
Co-authored-by: Paula Carneiro <paula.carneiro@remote.com>
  • Loading branch information
3 people committed Mar 26, 2024
1 parent c3559e6 commit 04c2598
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 35 deletions.
41 changes: 41 additions & 0 deletions src/tests/const.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,44 @@ describe('const/default with forced values', () => {
expect(fields[0]).toMatchObject({ forcedValue: 300 });
});
});

describe('OneOf const', () => {
it('Validates numbers or strings correctly', () => {
const { handleValidation } = createHeadlessForm(
{
properties: {
number: { type: 'number', oneOf: [{ const: 0 }, { const: 1 }, { const: 2 }] },
},
},
{ strictInputType: false }
);
expect(handleValidation({}).formErrors).toEqual(undefined);
expect(handleValidation({ number: 3 }).formErrors).toEqual({
number: 'The option 3 is not valid.',
});
expect(handleValidation({ number: 2 }).formErrors).toBeUndefined();
expect(handleValidation({ number: '2' }).formErrors).toEqual({
number: 'The option "2" is not valid.',
});
});

it('Validates numbers or strings when type is an array with null', () => {
const { handleValidation } = createHeadlessForm(
{
properties: {
number: { type: ['number', 'null'], oneOf: [{ const: 0 }, { const: 1 }, { const: 2 }] },
},
},
{ strictInputType: false }
);
expect(handleValidation({}).formErrors).toEqual(undefined);
expect(handleValidation({ number: 3 }).formErrors).toEqual({
number: 'The option 3 is not valid.',
});
expect(handleValidation({ number: 2 }).formErrors).toBeUndefined();
expect(handleValidation({ number: '2' }).formErrors).toEqual({
number: 'The option "2" is not valid.',
});
expect(handleValidation({ number: null }).formErrors).toEqual(undefined);
});
});
39 changes: 39 additions & 0 deletions src/tests/createHeadlessForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2028,6 +2028,45 @@ describe('createHeadlessForm', () => {
});
});

it('supports oneOf number const', () => {
const result = createHeadlessForm({
type: 'object',
additionalProperties: false,
properties: {
pets: {
title: 'How many pets?',
oneOf: [
{
title: 'One',
const: 0,
},
{
title: 'Two',
const: 2,
},
{
title: 'null',
const: 1,
},
],
'x-jsf-presentation': {
inputType: 'select',
},
type: ['number', 'null'],
},
},
required: [],
'x-jsf-order': ['pets'],
});

const fieldValidator = result.fields[0].schema;

expect(fieldValidator.isValidSync(0)).toBe(true);
expect(fieldValidator.isValidSync(1)).toBe(true);
expect(() => fieldValidator.validateSync('2')).toThrowError('The option "2" is not valid.');
expect(fieldValidator.isValidSync(null)).toBe(true);
});

describe('x-jsf-presentation attribute', () => {
it('support field with "x-jsf-presentation.statement"', () => {
const result = createHeadlessForm(schemaInputWithStatement);
Expand Down
7 changes: 4 additions & 3 deletions src/tests/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const mockTextMaxLengthInput = {
export const mockRadioInputDeprecated = {
title: 'Has siblings',
description: 'Do you have any siblings?',
type: 'string',
enum: ['yes', 'no'],
'x-jsf-presentation': {
inputType: 'radio',
Expand Down Expand Up @@ -386,6 +387,7 @@ export const mockGroupArrayInput = {
],
},
title: 'Child Sex',
type: 'string',
},
},
'x-jsf-order': ['full_name', 'birthdate', 'sex'],
Expand Down Expand Up @@ -842,7 +844,7 @@ export const mockRadioInputOptionalNull = {
{ const: 'yes', title: 'Yes' },
{ const: 'no', title: 'No' },
// JSF excludes the null option from the field output
// But keepts null as an accepted value
// But keeps null as an accepted value
{ const: null, title: 'N/A' },
],
'x-jsf-presentation': { inputType: 'radio' },
Expand Down Expand Up @@ -1612,7 +1614,6 @@ export const schemaFieldsetScopedCondition = {
properties: {
has_child: {
description: 'If yes, it will show its age.',
maximum: 100,
'x-jsf-presentation': {
inputType: 'radio',
options: [
Expand All @@ -1627,7 +1628,7 @@ export const schemaFieldsetScopedCondition = {
],
},
title: 'Do you have a child?',
type: 'number',
type: 'string',
},
age: {
description: 'This age is required, but the "age" at the root level is still optional.',
Expand Down
92 changes: 60 additions & 32 deletions src/yupSchema.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import flow from 'lodash/flow';
import noop from 'lodash/noop';
import { randexp } from 'randexp';
import { string, number, boolean, object, array } from 'yup';
import { string, number, boolean, object, array, mixed } from 'yup';

import { supportedTypes } from './internals/fields';
import { yupSchemaWithCustomJSONLogic } from './jsonLogic';
Expand Down Expand Up @@ -58,9 +58,27 @@ const validateMaxDate = (value, minDate) => {
return compare === 'LESSER' || compare === 'EQUAL' ? true : false;
};

/*
Custom test determines if the value either:
- Matches a specific option by value
- Matches a pattern
If the option is undefined do not test, to allow for optional fields.
*/
const validateRadioOrSelectOptions = (value, options) => {
if (value === undefined) return true;

const exactMatch = options.some((option) => option.value === value);

if (exactMatch) return true;

const patternMatch = options.some((option) => option.pattern?.test(value));

return !!patternMatch;
};

const yupSchemas = {
text: validateOnlyStrings,
radioOrSelect: (options) => {
radioOrSelectString: (options) => {
return string()
.nullable()
.transform((value) => {
Expand Down Expand Up @@ -97,40 +115,16 @@ const yupSchemas = {
*/
})
.test(
/*
Custom test determines if the value either:
- Matches a specific option by value
- Matches a pattern
If the option is undefined do not test, to allow for optional fields.
*/
'matchesOptionOrPattern',
({ value }) => `The option ${JSON.stringify(value)} is not valid.`,
(value) => {
if (value === undefined) {
return true; // [2]
}

const exactMatch = options.some((option) => option.value === value);

if (exactMatch) {
return true;
}

const patternMatch = options.some((option) => option.pattern?.test(value));

if (patternMatch) {
return true;
}

return false;
}
(value) => validateRadioOrSelectOptions(value, options)
);
},
date: ({ minDate, maxDate }) => {
let dateString = string()
.nullable()
.transform((value) => {
// @BUG RMT-518 - Same reason to radioOrSelect above.
// @BUG RMT-518 - Same reason to radioOrSelectString above.
if (value === '') {
return undefined;
}
Expand All @@ -157,7 +151,28 @@ const yupSchemas = {

return dateString;
},
radioOrSelectNumber: (options) =>
mixed()
.typeError('The value must be a number')
.transform((value) => {
// @BUG RMT-518 - Same reason to radioOrSelectString above.
if (options?.some((option) => option.value === null)) {
return value;
}
return value === null ? undefined : value;
})
.test(
'matchesOptionOrPattern',
({ value }) => {
return `The option ${JSON.stringify(value)} is not valid.`;
},
(value) => {
if (value !== undefined && typeof value !== 'number') return false;

return validateRadioOrSelectOptions(value, options);
}
)
.nullable(),
number: number().typeError('The value must be a number').nullable(),
file: array().nullable(),
email: string().trim().email('Please enter a valid email address').nullable(),
Expand Down Expand Up @@ -187,10 +202,11 @@ function getRequiredErrorMessage(inputType, { inlineError, configError }) {
return 'Required field';
}

const getJsonTypeInArray = (jsonType) =>
Array.isArray(jsonType)
const getJsonTypeInArray = (jsonType) => {
return Array.isArray(jsonType)
? jsonType.find((val) => val !== 'null') // eg ["string", "null"] // optional fields - get the head type.
: jsonType; // eg "string"
};

const getOptions = (field) => {
const allValues = field.options?.map((option) => ({
Expand All @@ -210,10 +226,22 @@ const getOptions = (field) => {

const getYupSchema = ({ inputType, ...field }) => {
const jsonType = getJsonTypeInArray(field.jsonType);
const hasOptions = field.options?.length > 0;

if (field.options?.length > 0) {
const generateOptionSchema = (type) => {
const optionValues = getOptions(field);
return yupSchemas.radioOrSelect(optionValues);
return type === 'number'
? yupSchemas.radioOrSelectNumber(optionValues)
: yupSchemas.radioOrSelectString(optionValues);
};

if (hasOptions) {
if (Array.isArray(field.jsonType)) {
return field.jsonType.includes('number')
? generateOptionSchema('number')
: generateOptionSchema('string');
}
return generateOptionSchema(field.jsonType);
}

if (field.format === 'date') {
Expand Down

0 comments on commit 04c2598

Please sign in to comment.