UNPKG

@remoteoss/json-schema-form

Version:

Headless UI form powered by JSON Schemas

1,588 lines (1,440 loc) 135 kB
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,