@remoteoss/json-schema-form
Version:
Headless UI form powered by JSON Schemas
1,588 lines (1,440 loc) • 135 kB
JavaScript
import isNil from 'lodash/isNil';
import omitBy from 'lodash/omitBy';
import { object } from 'yup';
import {
JSONSchemaBuilder,
schemaInputTypeText,
schemaInputTypeRadioDeprecated,
schemaInputTypeRadioString,
schemaInputTypeRadioStringY,
schemaInputTypeRadioBoolean,
schemaInputTypeRadioNumber,
schemaInputTypeRadioRequiredAndOptional,
schemaInputRadioOptionalNull,
schemaInputRadioOptionalConventional,
schemaInputTypeRadioOptionsWithDetails,
schemaInputTypeRadioWithoutOptions,
schemaInputTypeSelectSoloDeprecated,
schemaInputTypeSelectSolo,
schemaInputTypeSelectMultipleDeprecated,
schemaInputTypeSelectMultiple,
schemaInputTypeSelectMultipleOptional,
schemaInputTypeFieldset,
schemaInputTypeIntegerNumber,
schemaInputTypeNumber,
schemaInputTypeNumberZeroMaximum,
schemaInputTypeDate,
schemaInputTypeEmail,
schemaInputWithStatement,
schemaInputTypeCheckbox,
schemaInputTypeCheckboxBooleans,
schemaInputTypeCheckboxBooleanConditional,
schemaInputTypeNull,
schemaWithOrderKeyword,
schemaWithPositionDeprecated,
schemaDynamicValidationConst,
schemaDynamicValidationMinimumMaximum,
schemaDynamicValidationMinLengthMaxLength,
schemaDynamicValidationContains,
schemaAnyOfValidation,
schemaWithoutInputTypes,
schemaWithoutTypes,
mockFileInput,
mockRadioCardInput,
mockRadioCardExpandableInput,
mockTelWithPattern,
mockTextInput,
mockTextInputDeprecated,
mockNumberInput,
mockNumberInputWithPercentageAndCustomRange,
mockTextPatternInput,
mockTextMaxLengthInput,
mockFieldset,
mockNestedFieldset,
mockGroupArrayInput,
schemaFieldsetScopedCondition,
schemaWithConditionalToFieldset,
schemaWithConditionalPresentationProperties,
schemaWithConditionalReadOnlyProperty,
schemaWithWrongConditional,
schemaWithConditionalAcknowledgementProperty,
schemaInputTypeNumberWithPercentage,
schemaForErrorMessageSpecificity,
jsfConfigForErrorMessageSpecificity,
schemaInputTypeFile,
} from './helpers';
import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils';
import { createHeadlessForm } from '@/createHeadlessForm';
function buildJSONSchemaInput({ presentationFields, inputFields = {}, required }) {
return {
type: 'object',
properties: {
test: {
description: 'Test description',
presentation: {
...presentationFields,
},
title: 'Test title',
type: 'number',
...inputFields,
},
},
required: required ? ['test'] : [],
};
}
function friendlyError({ formErrors }) {
// destruct the formErrors directly
return formErrors;
}
// Get a field by name recursively
// eg getField(demo, "age") -> returns "age" field
// eg getField(demo, child, name) -> returns "child.name" subfield
const getField = (fields, name, ...subNames) => {
const field = fields.find((f) => f.name === name);
if (subNames.length > 0) {
return getField(field.fields, ...subNames);
}
return field;
};
beforeEach(mockConsole);
afterEach(restoreConsoleAndEnsureItWasNotCalled);
describe('createHeadlessForm', () => {
it('returns empty result given no schema', () => {
const result = createHeadlessForm();
expect(result).toMatchObject({
fields: [],
});
expect(result.isError).toBe(false);
expect(result.error).toBeFalsy();
});
it('returns an error given invalid schema', () => {
const result = createHeadlessForm({ foo: 1 });
expect(result.fields).toHaveLength(0);
expect(result.isError).toBe(true);
expect(console.error).toHaveBeenCalledWith(`JSON Schema invalid!`, expect.any(Error));
console.error.mockClear();
expect(result.error.message).toBe(`Cannot convert undefined or null to object`);
});
describe('field support fallback', () => {
it('sets type from presentation.inputType', () => {
const { fields } = createHeadlessForm({
properties: {
age: {
title: 'Age',
presentation: { inputType: 'number' },
type: 'number',
},
starting_time: {
title: 'Starting time',
presentation: {
inputType: 'hour', // Arbitrary types are accepted
set: 'AM', // And even any arbitrary presentation keys
},
type: 'string',
},
},
});
const { schema: yupSchema1, ...fieldAge } = omitBy(fields[0], isNil);
const { schema: yupSchema2, ...fieldTime } = omitBy(fields[1], isNil);
expect(yupSchema1).toEqual(expect.any(Object));
expect(fieldAge).toMatchObject({
inputType: 'number',
jsonType: 'number',
type: 'number',
});
expect(yupSchema1).toEqual(expect.any(Object));
expect(fieldTime).toMatchObject({
inputType: 'hour',
jsonType: 'string',
name: 'starting_time',
type: 'hour',
set: 'AM',
});
});
it('fails given a json schema without inputType', () => {
const { fields, error } = createHeadlessForm({
properties: {
test: { type: 'string' },
},
});
expect(fields).toHaveLength(0);
expect(error.message).toContain('Strict error: Missing inputType to field "test"');
expect(console.error).toHaveBeenCalledWith(`JSON Schema invalid!`, expect.any(Error));
console.error.mockClear();
});
function extractTypeOnly(listOfFields) {
const list = Array.isArray(listOfFields) ? listOfFields : listOfFields?.(); // handle fieldset + group-array
return list?.map(
({ name, type, inputType, jsonType, label, options, fields: nestedFields }) => {
return omitBy(
{
name,
type, // @deprecated
inputType,
jsonType,
label,
options,
fields: extractTypeOnly(nestedFields),
},
isNil
);
}
);
}
it('given a json schema without inputType, sets type based on json type (when strictInputType:false)', () => {
const { fields } = createHeadlessForm(schemaWithoutInputTypes, {
strictInputType: false,
});
const fieldsByNameAndType = extractTypeOnly(fields);
expect(fieldsByNameAndType).toMatchInlineSnapshot(`
[
{
"inputType": "text",
"jsonType": "string",
"label": "A string -> text",
"name": "a_string",
"type": "text",
},
{
"inputType": "radio",
"jsonType": "string",
"label": "A string with oneOf -> radio",
"name": "a_string_oneOf",
"options": [
{
"label": "Yes",
"value": "yes",
},
{
"label": "No",
"value": "no",
},
],
"type": "radio",
},
{
"inputType": "email",
"jsonType": "string",
"label": "A string with format:email -> email",
"name": "a_string_email",
"type": "email",
},
{
"inputType": "date",
"jsonType": "string",
"label": "A string with format:email -> date",
"name": "a_string_date",
"type": "date",
},
{
"inputType": "file",
"jsonType": "string",
"label": "A string with format:data-url -> file",
"name": "a_string_file",
"type": "file",
},
{
"inputType": "number",
"jsonType": "number",
"label": "A number -> number",
"name": "a_number",
"type": "number",
},
{
"inputType": "number",
"jsonType": "integer",
"label": "A integer -> number",
"name": "a_integer",
"type": "number",
},
{
"inputType": "checkbox",
"jsonType": "boolean",
"label": "A boolean -> checkbox",
"name": "a_boolean",
"type": "checkbox",
},
{
"fields": [
{
"inputType": "text",
"jsonType": "string",
"name": "foo",
"type": "text",
},
{
"inputType": "text",
"jsonType": "string",
"name": "bar",
"type": "text",
},
],
"inputType": "fieldset",
"jsonType": "object",
"label": "An object -> fieldset",
"name": "a_object",
"type": "fieldset",
},
{
"inputType": "select",
"jsonType": "array",
"label": "An array items.anyOf -> select",
"name": "a_array_items",
"options": [
{
"label": "Chrome",
"value": "chr",
},
{
"label": "Firefox",
"value": "ff",
},
{
"label": "Internet Explorer",
"value": "ie",
},
],
"type": "select",
},
{
"fields": [
{
"inputType": "text",
"jsonType": "string",
"label": "Role",
"name": "role",
"type": "text",
},
{
"inputType": "number",
"jsonType": "number",
"label": "Years",
"name": "years",
"type": "number",
},
],
"inputType": "group-array",
"jsonType": "array",
"label": "An array items.properties -> group-array",
"name": "a_array_properties",
"type": "group-array",
},
{
"inputType": "text",
"label": "A void -> text",
"name": "a_void",
"type": "text",
},
]
`);
});
it('given a json schema without json type, sets type based on structure (when strictInputType:false)', () => {
const { fields } = createHeadlessForm(schemaWithoutTypes, {
strictInputType: false,
});
const fieldsByNameAndType = extractTypeOnly(fields);
expect(fieldsByNameAndType).toMatchInlineSnapshot(`
[
{
"inputType": "text",
"label": "Default -> text",
"name": "default",
"type": "text",
},
{
"inputType": "radio",
"label": "With oneOf -> radio",
"name": "with_oneOf",
"options": [
{
"label": "Yes",
"value": "yes",
},
{
"label": "No",
"value": "no",
},
],
"type": "radio",
},
{
"inputType": "email",
"label": "With format:email -> email",
"name": "with_email",
"type": "email",
},
{
"inputType": "select",
"label": "With properties -> fieldset",
"name": "with_object",
"type": "select",
},
{
"inputType": "text",
"label": "With items.anyOf -> select",
"name": "with_items_anyOf",
"options": [
{
"label": "Chrome",
"value": "chr",
},
{
"label": "Firefox",
"value": "ff",
},
{
"label": "Internet Explorer",
"value": "ie",
},
],
"type": "text",
},
{
"fields": [
{
"inputType": "text",
"label": "Role",
"name": "role",
"type": "text",
},
{
"inputType": "text",
"label": "Years",
"name": "years",
"type": "text",
},
],
"inputType": "group-array",
"label": "With items.properties -> group-array",
"name": "with_items_properties",
"type": "group-array",
},
]
`);
});
});
describe('field support', () => {
function assertOptionsAllowed({ handleValidation, fieldName, validOptions, type = 'string' }) {
const validateForm = (vals) => friendlyError(handleValidation(vals));
// All allowed options are valid
validOptions.forEach((value) => {
expect(validateForm({ [fieldName]: value })).toBeUndefined();
});
if (type === 'string') {
// Any other arbitrary value is not valid.
expect(validateForm({ [fieldName]: 'blah-blah' })).toEqual({
[fieldName]: 'The option "blah-blah" is not valid.',
});
// As required field, empty string ("") is also considered empty. @BUG RMT-518
// Expectation: The error to be "The option '' is not valid."
expect(validateForm({ [fieldName]: '' })).toEqual({
[fieldName]: 'Required field',
});
}
// Given undefined, it says it's a required field.
expect(validateForm({})).toEqual({
[fieldName]: 'Required field',
});
// As required field, null is also considered empty @BUG RMT-518
// Expectation: The error to be "The option null is not valid."
expect(validateForm({ [fieldName]: null })).toEqual({
[fieldName]: 'Required field',
});
}
it('support "text" field type', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeText);
expect(fields[0]).toMatchObject({
description: 'Your username (max 10 characters)',
label: 'Username',
name: 'username',
required: true,
schema: expect.any(Object),
inputType: 'text',
jsonType: 'string',
maskSecret: 2,
maxLength: 10,
isVisible: true,
});
const fieldValidator = fields[0].schema;
expect(fieldValidator.isValidSync('CI007')).toBe(true);
expect(fieldValidator.isValidSync(true)).toBe(false);
expect(fieldValidator.isValidSync(1)).toBe(false);
expect(fieldValidator.isValidSync(0)).toBe(false);
expect(handleValidation({ username: 1 }).formErrors).toEqual({
username: 'username must be a `string` type, but the final value was: `1`.',
});
expect(() => fieldValidator.validateSync('')).toThrowError('Required field');
});
describe('support "select" field type', () => {
it('support "select" field type @deprecated', () => {
const { fields, handleValidation } = createHeadlessForm(
schemaInputTypeSelectSoloDeprecated
);
expect(fields).toMatchObject([
{
description: 'Life Insurance',
label: 'Benefits (solo)',
name: 'benefits',
placeholder: 'Select...',
type: 'select',
options: [
{
label: 'Medical Insurance',
value: 'Medical Insurance',
},
{
label: 'Health Insurance',
value: 'Health Insurance',
},
{
label: 'Travel Bonus',
value: 'Travel Bonus',
},
],
},
]);
assertOptionsAllowed({
handleValidation,
fieldName: 'benefits',
validOptions: ['Medical Insurance', 'Health Insurance', 'Travel Bonus'],
});
});
it('support "select" field type', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeSelectSolo);
const fieldSelect = fields[0];
expect(fieldSelect).toMatchObject({
name: 'browsers',
label: 'Browsers (solo)',
description: 'This solo select also includes a disabled option.',
options: [
{
value: 'chr',
label: 'Chrome',
},
{
value: 'ff',
label: 'Firefox',
},
{
value: 'ie',
label: 'Internet Explorer',
disabled: true,
},
],
});
expect(fieldSelect).not.toHaveProperty('multiple');
assertOptionsAllowed({
handleValidation,
fieldName: 'browsers',
validOptions: ['chr', 'ff', 'ie'],
});
});
it('support "select" field type from anyOf', () => {
const schema = {
type: 'object',
properties: {
browser: {
type: 'string',
anyOf: [
{ title: 'Chrome', const: 'chr' },
{ title: 'Firefox', const: 'ff' },
{ title: 'Add new option', pattern: '.*' },
],
'x-jsf-presentation': {
inputType: 'select',
},
},
},
};
const { fields } = createHeadlessForm(schema);
const fieldSelect = fields[0];
expect(fieldSelect).toMatchObject({
options: [
{
value: 'chr',
label: 'Chrome',
},
{
value: 'ff',
label: 'Firefox',
},
{
label: 'Add new option',
pattern: '.*',
},
],
});
});
it('supports "select" field type with multiple options @deprecated', () => {
const result = createHeadlessForm(schemaInputTypeSelectMultipleDeprecated);
expect(result).toMatchObject({
fields: [
{
description: 'Life Insurance',
label: 'Benefits (multiple)',
name: 'benefits_multi',
placeholder: 'Select...',
type: 'select',
options: [
{
label: 'Medical Insurance',
value: 'Medical Insurance',
},
{
label: 'Health Insurance',
value: 'Health Insurance',
},
{
label: 'Travel Bonus',
value: 'Travel Bonus',
},
],
multiple: true,
},
],
});
});
it('supports "select" field type with multiple options', () => {
const result = createHeadlessForm(schemaInputTypeSelectMultiple);
expect(result).toMatchObject({
fields: [
{
name: 'browsers_multi',
label: 'Browsers (multiple)',
description: 'This multi-select also includes a disabled option.',
options: [
{
value: 'chr',
label: 'Chrome',
},
{
value: 'ff',
label: 'Firefox',
},
{
value: 'ie',
label: 'Internet Explorer',
disabled: true,
},
],
multiple: true,
},
],
});
});
it('supports "select" field type with multiple options and optional', () => {
const result = createHeadlessForm(schemaInputTypeSelectMultipleOptional);
expect(result).toMatchObject({
fields: [
{
name: 'browsers_multi_optional',
label: 'Browsers (multiple) (optional)',
description: 'This optional multi-select also includes a disabled option.',
options: [
{
value: 'chr',
label: 'Chrome',
},
{
value: 'ff',
label: 'Firefox',
},
{
value: 'ie',
label: 'Internet Explorer',
disabled: true,
},
],
multiple: true,
},
],
});
});
});
describe('support "radio" field type', () => {
it('support "radio" field type @deprecated', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeRadioDeprecated);
expect(fields).toMatchObject([
{
description: 'Do you have any siblings?',
label: 'Has siblings',
name: 'has_siblings',
options: [
{
label: 'Yes',
value: 'yes',
},
{
label: 'No',
value: 'no',
},
],
required: true,
schema: expect.any(Object),
type: 'radio',
},
]);
assertOptionsAllowed({
handleValidation,
fieldName: 'has_siblings',
validOptions: ['yes', 'no'],
});
});
it('support "radio" field string type', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeRadioString);
expect(fields).toMatchObject([
{
description: 'Do you have any siblings?',
label: 'Has siblings',
name: 'has_siblings',
options: [
{
label: 'Yes',
value: 'yes',
},
{
label: 'No',
value: 'no',
},
],
required: true,
schema: expect.any(Object),
type: 'radio',
},
]);
assertOptionsAllowed({
handleValidation,
fieldName: 'has_siblings',
validOptions: ['yes', 'no'],
});
});
it('support "radio" field boolean type', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeRadioBoolean);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(fields).toMatchObject([
{
description: 'Are you over 18 years old?',
label: 'Over 18',
name: 'over_18',
options: [
{
label: 'Yes',
value: true,
},
{
label: 'No',
value: false,
},
],
required: true,
schema: expect.any(Object),
type: 'radio',
},
]);
assertOptionsAllowed({
handleValidation,
fieldName: 'over_18',
validOptions: [true, false],
type: schemaInputTypeRadioBoolean.properties.over_18.type,
});
expect(validateForm({ over_18: 'true' })).toEqual({
over_18: 'The option "true" is not valid.',
});
});
it('supports "radio" field type with its "card" and "card-expandable" variants', () => {
const result = createHeadlessForm(
JSONSchemaBuilder()
.addInput({
experience_level: mockRadioCardExpandableInput,
payment_method: mockRadioCardInput,
})
.build()
);
expect(result).toMatchObject({
fields: [
{
description:
'Please select the experience level that aligns with this role based on the job description (not the employees overall experience)',
label: 'Experience level',
name: 'experience_level',
type: 'radio',
required: false,
variant: 'card-expandable',
options: [
{
label: 'Junior level',
value: 'junior',
description:
'Entry level employees who perform tasks under the supervision of a more experienced employee.',
},
{
label: 'Mid level',
value: 'mid',
description:
'Employees who perform tasks with a good degree of autonomy and/or with coordination and control functions.',
},
{
label: 'Senior level',
value: 'senior',
description:
'Employees who perform tasks with a high degree of autonomy and/or with coordination and control functions.',
},
],
},
{
description: 'Chose how you want to be paid',
label: 'Payment method',
name: 'payment_method',
type: 'radio',
variant: 'card',
required: false,
options: [
{
label: 'Credit Card',
value: 'cc',
description: 'Plastic money, which is still money',
},
{
label: 'Cash',
value: 'cash',
description: 'Rules Everything Around Me',
},
],
},
],
});
});
// @BUG COD-1859
// it should validate when type is string but value is not a boolean
it('support "radio" field string-y type', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeRadioStringY);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(fields).toMatchObject([
{
description: 'Do you have any siblings?',
label: 'Has siblings',
name: 'has_siblings',
options: [
{
label: 'Yes',
value: 'true',
},
{
label: 'No',
value: 'false',
},
],
required: true,
schema: expect.any(Object),
type: 'radio',
},
]);
assertOptionsAllowed({
handleValidation,
fieldName: 'has_siblings',
validOptions: ['true', 'false'],
});
expect(validateForm({ has_siblings: false })).toEqual({
has_siblings: 'The option "false" is not valid.',
});
});
it('support "radio" field number type', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeRadioNumber);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(fields).toMatchObject([
{
description: 'How many siblings do you have?',
label: 'Number of siblings',
name: 'siblings_count',
options: [
{
label: 'One',
value: 1,
},
{
label: 'Two',
value: 2,
},
{
label: 'Three',
value: 3,
},
],
required: true,
schema: expect.any(Object),
type: 'radio',
},
]);
assertOptionsAllowed({
handleValidation,
fieldName: 'siblings_count',
validOptions: [1, 2, 3],
type: schemaInputTypeRadioNumber.properties.siblings_count.type,
});
expect(validateForm({ siblings_count: '3' })).toEqual({
siblings_count: 'The option "3" is not valid.',
});
});
it('support "radio" optional field', () => {
const { fields, handleValidation } = createHeadlessForm(
schemaInputTypeRadioRequiredAndOptional
);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(fields).toMatchObject([
{},
{
name: 'has_car',
label: 'Has car',
description: 'Do you have a car? (optional field, check oneOf)',
options: [
{
label: 'Yes',
value: 'yes',
},
{
label: 'No',
value: 'no',
},
],
required: false,
schema: expect.any(Object),
type: 'radio',
},
]);
expect(
validateForm({
has_siblings: 'yes',
has_car: 'yes',
})
).toBeUndefined();
expect(validateForm({})).toEqual({
has_siblings: 'Required field',
});
});
function assertCommonBehavior(validateForm) {
// Note: Very similar to assertOptionsAllowed()
// We could reuse it in a next iteration.
// Happy path
expect(validateForm({ has_car: 'yes' })).toBeUndefined();
// Accepts undefined field
expect(validateForm({})).toBeUndefined();
// Does not accept other values
expect(validateForm({ has_car: 'blah-blah' })).toEqual({
has_car: 'The option "blah-blah" is not valid.',
});
// Does not accept "null" as string
expect(validateForm({ has_car: 'null' })).toEqual({
has_car: 'The option "null" is not valid.',
});
// Accepts empty string ("") — @BUG RMT-518
// Expectation: Does not accept empty string ("")
expect(validateForm({ has_car: '' })).toBeUndefined();
}
it('support "radio" optional field - optional (conventional way) - @BUG RMT-518', () => {
const { handleValidation } = createHeadlessForm(schemaInputRadioOptionalConventional);
const validateForm = (vals) => friendlyError(handleValidation(vals));
assertCommonBehavior(validateForm);
// Accepts null, even though it shouldn't @BUG RMT-518
// This is for cases where we (Remote) still have incorrect
// JSON Schemas in our Platform.
expect(validateForm({ has_car: null })).toBeUndefined();
// Expected:
// // Does NOT accept null value
// expect(validateForm({ has_car: null })).toEqual({
// has_car: 'The option null is not valid.',
// });
});
it('support "radio" optional field - optional with null option (as Remote does) - @BUG RMT-518', () => {
const { handleValidation } = createHeadlessForm(schemaInputRadioOptionalNull);
const validateForm = (vals) => friendlyError(handleValidation(vals));
assertCommonBehavior(validateForm);
// Accepts null value
expect(validateForm({ has_car: null })).toBeUndefined();
});
it('support "radio" field type with extra info inside each option', () => {
const result = createHeadlessForm(schemaInputTypeRadioOptionsWithDetails);
expect(result.fields).toHaveLength(1);
const fieldOptions = result.fields[0].options;
// The x-jsf-presentation content was spread to the root:
expect(fieldOptions[0]).not.toHaveProperty('x-jsf-presentation');
expect(fieldOptions).toEqual([
{
label: 'Basic',
value: 'basic',
meta: {
displayCost: '$30.00/mo',
},
// Other x-* keywords are kept as it is.
'x-another': 'extra-thing',
},
{
label: 'Standard',
value: 'standard',
meta: {
displayCost: '$50.00/mo',
},
},
]);
});
it('supports oneOf pattern validation', () => {
const result = createHeadlessForm(mockTelWithPattern);
expect(result).toMatchObject({
fields: [
{
label: 'Phone number',
name: 'phone_number',
type: 'tel',
required: false,
options: [
{
label: 'Portugal',
pattern: '^(\\+351)[0-9]{9,}$',
},
{
label: 'United Kingdom (UK)',
pattern: '^(\\+44)[0-9]{1,}$',
},
{
label: 'Bolivia',
pattern: '^(\\+591)[0-9]{9,}$',
},
{
label: 'Canada',
pattern: '^(\\+1)(206|224)[0-9]{1,}$',
},
{
label: 'United States',
pattern: '^(\\+1)[0-9]{1,}$',
},
],
},
],
});
const fieldValidator = result.fields[0].schema;
expect(fieldValidator.isValidSync('+351123123123')).toBe(true);
expect(() => fieldValidator.validateSync('+35100')).toThrowError(
'The option "+35100" is not valid.'
);
expect(fieldValidator.isValidSync(undefined)).toBe(true);
});
it('support "radio" field type without oneOf options', () => {
const result = createHeadlessForm(schemaInputTypeRadioWithoutOptions);
expect(result.fields).toHaveLength(1);
const fieldOptions = result.fields[0].options;
expect(fieldOptions).toEqual([]);
});
});
it('support "integer" field type', () => {
const result = createHeadlessForm(schemaInputTypeIntegerNumber);
expect(result).toMatchObject({
fields: [
{
description: 'How many open tabs do you have?',
label: 'Tabs',
name: 'tabs',
required: false,
schema: expect.any(Object),
type: 'number',
jsonType: 'integer',
inputType: 'number',
minimum: 1,
maximum: 10,
},
],
});
const fieldValidator = result.fields[0].schema;
expect(fieldValidator.isValidSync('0')).toBe(false);
expect(fieldValidator.isValidSync('10')).toBe(true);
expect(fieldValidator.isValidSync('11')).toBe(false);
expect(fieldValidator.isValidSync('5.5')).toBe(false);
expect(fieldValidator.isValidSync('1.0')).toBe(true);
expect(fieldValidator.isValidSync('this is text with a number 1')).toBe(false);
expect(() => fieldValidator.validateSync('5.5')).toThrowError(
'Must not contain decimal points. E.g. 5 instead of 5.5'
);
expect(() => fieldValidator.validateSync('some text')).toThrowError(
'The value must be a number'
);
expect(() => fieldValidator.validateSync('')).toThrowError('The value must be a number');
});
it('support "number" field type', () => {
const result = createHeadlessForm(schemaInputTypeNumber);
expect(result).toMatchObject({
fields: [
{
description: 'How many open tabs do you have?',
label: 'Tabs',
name: 'tabs',
required: true,
schema: expect.any(Object),
type: 'number',
minimum: 1,
maximum: 10,
},
],
});
const fieldValidator = result.fields[0].schema;
expect(fieldValidator.isValidSync('0')).toBe(false);
expect(fieldValidator.isValidSync('10')).toBe(true);
expect(fieldValidator.isValidSync('11')).toBe(false);
expect(fieldValidator.isValidSync('this is text with a number 1')).toBe(false);
expect(() => fieldValidator.validateSync('some text')).toThrowError(
'The value must be a number'
);
expect(() => fieldValidator.validateSync('')).toThrowError('The value must be a number');
});
it('support "number" field type with the percentage attribute', () => {
const result = createHeadlessForm(schemaInputTypeNumberWithPercentage);
expect(result).toMatchObject({
fields: [
{
description: 'What % of shares do you own?',
label: 'Shares',
name: 'shares',
percentage: true,
required: true,
schema: expect.any(Object),
type: 'number',
minimum: 1,
maximum: 100,
},
],
});
const fieldValidator = result.fields[0].schema;
const { percentage } = result.fields[0];
expect(fieldValidator.isValidSync('0')).toBe(false);
expect(fieldValidator.isValidSync('10')).toBe(true);
expect(fieldValidator.isValidSync('101')).toBe(false);
expect(fieldValidator.isValidSync('this is text with a number 1')).toBe(false);
expect(() => fieldValidator.validateSync('some text')).toThrowError(
'The value must be a number'
);
expect(() => fieldValidator.validateSync('')).toThrowError('The value must be a number');
expect(percentage).toBe(true);
});
it('support "number" field type with the percentage attribute and custom range values', () => {
const result = createHeadlessForm(
JSONSchemaBuilder()
.addInput({
shares: {
...mockNumberInputWithPercentageAndCustomRange,
},
})
.setRequiredFields(['shares'])
.build()
);
expect(result).toMatchObject({
fields: [
{
description: 'What % of shares do you own?',
label: 'Shares',
name: 'shares',
percentage: true,
required: true,
schema: expect.any(Object),
type: 'number',
minimum: 50,
maximum: 70,
},
],
});
const fieldValidatorCustom = result.fields[0].schema;
const { percentage: percentageCustom } = result.fields[0];
expect(fieldValidatorCustom.isValidSync('0')).toBe(false);
expect(fieldValidatorCustom.isValidSync('49')).toBe(false);
expect(fieldValidatorCustom.isValidSync('55')).toBe(true);
expect(fieldValidatorCustom.isValidSync('70')).toBe(true);
expect(fieldValidatorCustom.isValidSync('101')).toBe(false);
expect(fieldValidatorCustom.isValidSync('this is text with a number 1')).toBe(false);
expect(() => fieldValidatorCustom.validateSync('some text')).toThrowError(
'The value must be a number'
);
expect(() => fieldValidatorCustom.validateSync('')).toThrowError(
'The value must be a number'
);
expect(percentageCustom).toBe(true);
});
it('support "date" field type', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeDate);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(fields[0]).toMatchObject({
label: 'Birthdate',
name: 'birthdate',
required: true,
schema: expect.any(Object),
type: 'date',
minDate: '1922-03-01',
maxDate: '2022-03-17',
});
const todayDateHint = new Date().toISOString().substring(0, 10);
expect(validateForm({})).toEqual({
birthdate: 'Required field',
});
expect(validateForm({ birthdate: '2020-10-10' })).toBeUndefined();
expect(validateForm({ birthdate: '2020-13-10' })).toEqual({
birthdate: `Must be a valid date in yyyy-mm-dd format. e.g. ${todayDateHint}`,
});
});
describe('support "date" field type', () => {
it('support "date" field type with a minDate', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeDate);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(fields[0]).toMatchObject({
label: 'Birthdate',
name: 'birthdate',
required: true,
schema: expect.any(Object),
type: 'date',
minDate: '1922-03-01',
maxDate: '2022-03-17',
});
expect(validateForm({})).toEqual({
birthdate: 'Required field',
});
expect(validateForm({ birthdate: '' })).toEqual({
birthdate: `Required field`,
});
expect(validateForm({ birthdate: '1922-02-01' })).toEqual({
birthdate: 'The date must be 1922-03-01 or after.',
});
expect(validateForm({ birthdate: '1922-03-01' })).toBeUndefined();
expect(validateForm({ birthdate: '2021-03-01' })).toBeUndefined();
});
it('support "date" field type with a maxDate', () => {
const { fields, handleValidation } = createHeadlessForm(schemaInputTypeDate);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(fields[0]).toMatchObject({
label: 'Birthdate',
name: 'birthdate',
required: true,
schema: expect.any(Object),
type: 'date',
minDate: '1922-03-01',
maxDate: '2022-03-17',
});
expect(validateForm({ birthdate: '' })).toEqual({
birthdate: `Required field`,
});
expect(validateForm({ birthdate: '2022-02-01' })).toBeUndefined();
expect(validateForm({ birthdate: '2022-03-01' })).toBeUndefined();
expect(validateForm({ birthdate: '2022-04-01' })).toEqual({
birthdate: 'The date must be 2022-03-17 or before.',
});
});
it('support format date with minDate and maxDate', () => {
const schemaFormatDate = {
properties: {
birthdate: {
title: 'Birthdate',
type: 'string',
format: 'date',
'x-jsf-presentation': {
inputType: 'myDateType',
maxDate: '2022-03-01',
minDate: '1922-03-01',
},
},
},
};
const { handleValidation } = createHeadlessForm(schemaFormatDate);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(validateForm({ birthdate: '1922-02-01' })).toEqual({
birthdate: 'The date must be 1922-03-01 or after.',
});
});
});
describe('supports "file" field type', () => {
it('supports "file" field type', () => {
const result = createHeadlessForm(
JSONSchemaBuilder()
.addInput({
fileInput: mockFileInput,
})
.build()
);
expect(result).toMatchObject({
fields: [
{
type: 'file',
fileDownload: 'http://some.domain.com/file-name.pdf',
description: 'File Input Description',
fileName: 'My File',
label: 'File Input',
name: 'fileInput',
required: false,
accept: '.png,.jpg,.jpeg,.pdf',
},
],
});
});
describe('when a field has accepted extensions', () => {
let fields;
beforeEach(() => {
const result = createHeadlessForm(
JSONSchemaBuilder().addInput({ fileInput: mockFileInput }).build()
);
fields = result.fields;
});
describe('and file is of incorrect format', () => {
const file = new File(['foo'], 'file.txt', {
type: 'text/plain',
});
it('should throw an error', async () =>
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [file] })
).rejects.toMatchObject({
errors: ['Unsupported file format. The acceptable formats are .png,.jpg,.jpeg,.pdf.'],
}));
});
describe('and file is of correct format', () => {
const file = new File(['foo'], 'file.png', {
type: 'image/png',
});
Object.defineProperty(file, 'size', { value: 1024 * 1024 });
const assertObj = { fileInput: [file] };
it('should validate field', async () =>
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [file] })
).resolves.toEqual(assertObj));
});
describe('and file is of correct but uppercase format ', () => {
const file = new File(['foo'], 'file.PNG', {
type: 'image/png',
});
Object.defineProperty(file, 'size', { value: 1024 * 1024 });
const assertObj = { fileInput: [file] };
it('should validate field', async () =>
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [file] })
).resolves.toEqual(assertObj));
});
describe('and file is not instance of a File', () => {
it('accepts if file object has name property', async () => {
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [{ name: 'foo.pdf' }] })
).resolves.toEqual({ fileInput: [{ name: 'foo.pdf' }] });
});
it('should validate format', async () =>
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [{ name: 'foo.txt' }] })
).rejects.toMatchObject({
errors: ['Unsupported file format. The acceptable formats are .png,.jpg,.jpeg,.pdf.'],
}));
it('should validate max size', async () =>
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [{ name: 'foo.txt', size: 1024 * 1024 * 1024 }] })
).rejects.toMatchObject({ errors: ['File size too large. The limit is 20 MB.'] }));
it('throw an error if invalid file object', async () =>
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [{ path: 'foo.txt' }] })
).rejects.toMatchObject({ errors: ['Not a valid file.'] }));
});
});
describe('when a field has max file size', () => {
let fields;
beforeEach(() => {
const result = createHeadlessForm(
JSONSchemaBuilder().addInput({ fileInput: mockFileInput }).build()
);
fields = result.fields;
});
describe('and file is greater than that', () => {
const file = new File([''], 'file.png');
Object.defineProperty(file, 'size', { value: 1024 * 1024 * 1024 });
it('should throw an error', async () =>
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [file] })
).rejects.toMatchObject({ errors: ['File size too large. The limit is 20 MB.'] }));
});
describe('and file is smaller than that', () => {
const file = new File([''], 'file.png');
Object.defineProperty(file, 'size', { value: 1024 * 1024 });
const assertObj = { fileInput: [file] };
it('should validate field', async () =>
expect(
object()
.shape({
fileInput: fields[0].schema,
})
.validate({ fileInput: [file] })
).resolves.toEqual(assertObj));
});
});
describe('when a field file is optional', () => {
it('it accepts an empty array', () => {
const result = createHeadlessForm(
JSONSchemaBuilder().addInput({ fileInput: mockFileInput }).build()
);
const emptyFile = { fileInput: [] };
expect(
object()
.shape({
fileInput: result.fields[0].schema,
})
.validate(emptyFile)
).resolves.toEqual(emptyFile);
});
it('it validates missing file correctly', () => {
const { handleValidation } = createHeadlessForm(
JSONSchemaBuilder().addInput({ fileInput: mockFileInput }).build()
);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(validateForm({})).toBeUndefined();
expect(validateForm({ fileInput: null })).toBeUndefined();
});
});
describe('when a field file is required', () => {
it('it validates missing file correctly', () => {
const { handleValidation } = createHeadlessForm(schemaInputTypeFile);
const validateForm = (vals) => friendlyError(handleValidation(vals));
expect(validateForm({})).toEqual({
a_file: 'Required field',
});
expect(
validateForm({
a_file: null,
})
).toEqual({
a_file: 'Required field',
});
});
});
});
describe('supports "group-array" field type', () => {
it('basic test', () => {
const result = createHeadlessForm(
JSONSchemaBuilder()
.addInput({
dependent_details: mockGroupArrayInput,