diff --git a/.github/media/jsf_logo.png b/.github/media/jsf_logo.png index c935377a..765f9fca 100644 Binary files a/.github/media/jsf_logo.png and b/.github/media/jsf_logo.png differ diff --git a/LICENSE b/LICENSE index f560f691..375c454d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,21 @@ -Copyright 2023 Remote Technology, Inc. +MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2023 Remote Technology, Inc. -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 38ee70da..8ff5cd81 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # json-schema-form -[logo](./github/media/jsf_logo.png) +![logo](.github/media/jsf_logo.png) Headless UI form library powered by [JSON Schema](https://json-schema.org/)s. JSF consumes JSON schemas and transforms them into data to be consumed by UI libraries. diff --git a/src/calculateConditionalProperties.js b/src/calculateConditionalProperties.js new file mode 100644 index 00000000..4ff6ad86 --- /dev/null +++ b/src/calculateConditionalProperties.js @@ -0,0 +1,124 @@ +import merge from 'lodash/merge'; +import omit from 'lodash/omit'; + +import { extractParametersFromNode } from './helpers'; +import { supportedTypes } from './internals/fields'; +import { getFieldDescription, pickXKey } from './internals/helpers'; +import { buildYupSchema } from './yupSchema'; +/** + * @typedef {import('./createHeadlessForm').FieldParameters} FieldParameters + */ + +/** + * Verifies if a field is required + * @param {Object} node - JSON schema parent node + * @param {String} inputName - input name + * @return {Boolean} + */ +function isFieldRequired(node, inputName) { + // For nested properties (case of fieldset) we need to check recursively + if (node?.required) { + return node.required.includes(inputName); + } + + return false; +} + +/** + * Loops recursively through fieldset fields and returns an copy version of them + * where the required property is updated. + * + * @param {Array} fields - list of fields of a fieldset + * @param {Object} property - property that relates with the list of fields + * @returns {Object} + */ +function rebuildInnerFieldsRequiredProperty(fields, property) { + if (property?.properties) { + return fields.map((field) => { + if (field.fields) { + return { + ...field, + fields: rebuildInnerFieldsRequiredProperty(field.fields, property.properties[field.name]), + }; + } + return { + ...field, + required: isFieldRequired(property, field.name), + }; + }); + } + + return fields.map((field) => ({ + ...field, + required: isFieldRequired(property, field.name), + })); +} + +/** + * Builds a function that updates the fields properties based on the form values and the + * dependencies the field has on the current schema. + * @param {FieldParameters} fieldParams - field parameters + * @returns {Function} + */ +export function calculateConditionalProperties(fieldParams, customProperties) { + /** + * Runs dynamic property calculation on a field based on a conditional that has been calculated + * @param {Boolean} isRequired - if the field is required + * @param {Object} conditionBranch - condition branch being applied + * @returns {Object} updated field parameters + */ + return (isRequired, conditionBranch) => { + // Check if the current field is conditionally declared in the schema + + const conditionalProperty = conditionBranch?.properties?.[fieldParams.name]; + + if (conditionalProperty) { + const presentation = pickXKey(conditionalProperty, 'presentation') ?? {}; + + const fieldDescription = getFieldDescription(conditionalProperty, customProperties); + + const newFieldParams = extractParametersFromNode({ + ...conditionalProperty, + ...fieldDescription, + }); + + let fieldSetFields; + + if (fieldParams.inputType === supportedTypes.FIELDSET) { + fieldSetFields = rebuildInnerFieldsRequiredProperty( + fieldParams.fields, + conditionalProperty + ); + newFieldParams.fields = fieldSetFields; + } + + const base = { + isVisible: true, + required: isRequired, + ...(presentation?.inputType && { type: presentation.inputType }), + schema: buildYupSchema({ + ...fieldParams, + ...newFieldParams, + // If there are inner fields (case of fieldset) they need to be updated based on the condition + fields: fieldSetFields, + required: isRequired, + }), + }; + + return omit(merge(base, presentation, newFieldParams), ['inputType']); + } + + // If field is not conditionally declared it should be visible if it's required + const isVisible = isRequired; + + return { + isVisible, + required: isRequired, + schema: buildYupSchema({ + ...fieldParams, + ...extractParametersFromNode(conditionBranch), + required: isRequired, + }), + }; + }; +} diff --git a/src/calculateCustomValidationProperties.js b/src/calculateCustomValidationProperties.js new file mode 100644 index 00000000..4851d0d1 --- /dev/null +++ b/src/calculateCustomValidationProperties.js @@ -0,0 +1,91 @@ +import inRange from 'lodash/inRange'; +import isFunction from 'lodash/isFunction'; +import isNil from 'lodash/isNil'; +import isObject from 'lodash/isObject'; +import mapValues from 'lodash/mapValues'; +import pick from 'lodash/pick'; + +import { pickXKey } from './internals/helpers'; +import { buildYupSchema } from './yupSchema'; + +export const SUPPORTED_CUSTOM_VALIDATION_FIELD_PARAMS = ['minimum', 'maximum']; + +const isCustomValidationAllowed = (fieldParams) => (customValidation, customValidationKey) => { + // don't apply custom validation in cases when the fn returns null. + if (isNil(customValidation)) { + return false; + } + + const { minimum, maximum } = fieldParams; + const isAllowed = inRange( + customValidation, + minimum ?? -Infinity, + maximum ? maximum + 1 : Infinity + ); + + if (!isAllowed) { + const errorMessage = `Custom validation for ${fieldParams.name} is not allowed because ${customValidationKey}:${customValidation} is less strict than the original range: ${minimum} to ${maximum}`; + + if (process.env.NODE_ENV === 'development') { + throw new Error(errorMessage); + } else { + // eslint-disable-next-line no-console + console.warn(errorMessage); + } + } + + return isAllowed; +}; + +export function calculateCustomValidationProperties(fieldParams, customProperties) { + return (isRequired, conditionBranch, formValues) => { + const params = { ...fieldParams, ...conditionBranch?.properties?.[fieldParams.name] }; + const presentation = pickXKey(params, 'presentation') ?? {}; + + const supportedParams = pick(customProperties, SUPPORTED_CUSTOM_VALIDATION_FIELD_PARAMS); + + const checkIfAllowed = isCustomValidationAllowed(params); + + const customErrorMessages = []; + const fieldParamsWithNewValidation = mapValues( + supportedParams, + (customValidationValue, customValidationKey) => { + const originalValidation = params[customValidationKey]; + + const customValidation = isFunction(customValidationValue) + ? customValidationValue(formValues, params) + : customValidationValue; + + if (isObject(customValidation)) { + if (checkIfAllowed(customValidation[customValidationKey], customValidationKey)) { + customErrorMessages.push(pickXKey(customValidation, 'errorMessage')); + + return customValidation[customValidationKey]; + } + + return originalValidation; + } + + return checkIfAllowed(customValidation, customValidationKey) + ? customValidation + : originalValidation; + } + ); + + const errorMessage = Object.assign({ ...params.errorMessage }, ...customErrorMessages); + + return { + ...params, + ...fieldParamsWithNewValidation, + type: presentation?.inputType || params.inputType, + errorMessage, + required: isRequired, + schema: buildYupSchema({ + ...params, + ...fieldParamsWithNewValidation, + errorMessage, + required: isRequired, + }), + }; + }; +} diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js new file mode 100644 index 00000000..6c2bf8f1 --- /dev/null +++ b/src/createHeadlessForm.js @@ -0,0 +1,345 @@ +import get from 'lodash/get'; +import isNil from 'lodash/isNil'; +import omit from 'lodash/omit'; +import omitBy from 'lodash/omitBy'; +import pick from 'lodash/pick'; +import size from 'lodash/size'; + +import { calculateConditionalProperties } from './calculateConditionalProperties'; +import { + calculateCustomValidationProperties, + SUPPORTED_CUSTOM_VALIDATION_FIELD_PARAMS, +} from './calculateCustomValidationProperties'; +import { + getPrefillValues, + updateFieldsProperties, + extractParametersFromNode, + handleValuesChange, +} from './helpers'; +import { + inputTypeMap, + _composeFieldCustomClosure, + _composeFieldArbitraryClosure, + supportedTypes, + getInputType, +} from './internals/fields'; +import { pickXKey } from './internals/helpers'; +import { buildYupSchema } from './yupSchema'; + +// Some type definitions (to be migrated into .d.ts file or TS Interfaces) +/** + * @typedef {Object} ParserFields + * @typedef {Object} YupErrors + * @typedef {Object} FieldValues + * @property {Object[]} fields - Fields to be use by an input + * @property {function(): void} validationSchema - Deprecated: A validation schema for Formik. (Use handleValidation instead) + * @property {function(FieldValues): YupErrors} handleValidation - Given field values, mutates the fields UI, and return Yup errors. + */ + +/** + * @typedef {'text'|'number'|'select'|'file'|'radio'|'group-array'|'email'|'date'|'checkbox'|'fieldset'} InputType + * @typedef {'string'|'boolean'|'object'|'array'|null} JsonType + +* */ + +/** + * @typedef {Object} FieldParameters + * @property {InputType} inputType - type of form input that the field represents + * @property {JsonType} jsonType - native json type + * @property {String} name - field name + * @property {String} [description] - field description + * @property {Boolean} required - indicates if the field is required + * @property {Boolean} [readOnly] - indicates if the field is read-only + * @property {Function} [calculateConditionalProperties] - function that updates field parameters + * @property {Boolean} [multiple] - wether the field accepts multiple values + * @property {String} [accept] - if inputType is file the accepted file types can be supplied in a comma separated string + */ + +/** + * @typedef {Object} JsfConfig + * @property {Object} config.initialValues - Initial values to evaluate the form against + * @param {Boolean} config.strictInputType - Disabled by default. When enabled, presentation.inputType is required. + * @param {Object} config.customProperties - Object of fields with custom attributes + * @param {Function|String} config.customProperties[].description - Override description for FieldParameters + * @param {*} config.customProperties[].* - Any other attribute is included in the FieldParameters + * @param {Object} config.inputTypes[].errorMessage.* - Custom error messages by each error type. eg errorMessage: { required: 'Cannot be empty' } + +*/ + +/** + * @typedef {Object} FieldCustomization + * @property {Function} [Component] - the custom component to be applied to the field + * @property {Function} [description] - a custom component that will be rendered in the field. This component receives + * the JSON-schema field description as a prop. + */ + +/** + * @typedef {Object.} CustomProperties - custom field properties (maps field names to a field customization) + */ + +function sortByOrderOrPosition(a, b, order) { + if (order) { + return order.indexOf(a.name) - order.indexOf(b.name); + } + // Fallback to deprecated position + return a.position - b.position; +} + +function removeInvalidAttributes(fields) { + return omit(fields, ['items', 'maxFileSize', 'isDynamic']); +} + +/** + * Handles a JSON schema node property by building the field parameters for that + * property name (field name) + * + * @param {String} name - property key (field name) + * @param {Object} fieldProperties - field properties + * @param {String[]} required - required fields + * + * @returns {FieldParameters} + */ +function buildFieldParameters(name, fieldProperties, required = [], config = {}) { + const { position } = pickXKey(fieldProperties, 'presentation') ?? {}; + let fields; + + const inputType = getInputType(fieldProperties, config.strictInputType, name); + + if (inputType === supportedTypes.FIELDSET) { + // eslint-disable-next-line no-use-before-define + fields = getFieldsFromJSONSchema(fieldProperties, { + customProperties: get(config, `customProperties.${name}`, {}), + }); + } + + const result = { + name, + inputType, + jsonType: fieldProperties.type, + type: inputType, // @deprecated in favor of inputType, + required: required?.includes(name) ?? false, + fields, + position, + ...extractParametersFromNode(fieldProperties), + }; + + return omitBy(result, isNil); +} + +/** + * Converts a JSON schema's properties into a list of field parameters + * + * @param {Object} node - JSON schema node + * @param {Object} node.properties - Properties of the schema node + * @param {String[]} node.required - List of required fields + * @returns {FieldParameters[]} list of FieldParameters + */ +function convertJSONSchemaPropertiesToFieldParameters( + { properties, required, 'x-jsf-order': order, ...params }, + config = {} +) { + const sortFields = (a, b) => sortByOrderOrPosition(a, b, order); + + // Gather fields represented at the root of the node , sort them by + // their position and then remove the position property (since it's no longer needed) + return Object.entries(properties) + .filter(([, value]) => typeof value === 'object') + .map(([key, value]) => buildFieldParameters(key, value, required, config)) + .sort(sortFields) + .map(({ position, ...fieldParams }) => fieldParams); +} + +/** + * Checks which fields have dependencies (dynamic behavior based on the form state) and marks them as such + * + * @param {FieldParameters[]} fieldsParameters - list of field parameters + * @param {Object} node - JSON schema node + */ +function applyFieldsDependencies(fieldsParameters, node) { + if (node?.then) { + fieldsParameters + .filter( + ({ name }) => + node.then?.properties?.[name] || + node.then?.required?.includes(name) || + node.else?.properties?.[name] || + node.else?.required?.includes(name) + ) + .forEach((property) => { + property.isDynamic = true; + }); + + applyFieldsDependencies(fieldsParameters, node.then); + } + + if (node?.anyOf) { + fieldsParameters + .filter(({ name }) => node.anyOf.some(({ required }) => required?.includes(name))) + .forEach((property) => { + property.isDynamic = true; + }); + + applyFieldsDependencies(fieldsParameters, node.then); + } + + if (node?.allOf) { + node.allOf.forEach((condition) => { + applyFieldsDependencies(fieldsParameters, condition); + }); + } +} + +/** + * Returns the custom properties for a field (if there are any) + * @param {FieldParameters} fieldParams - field parameters + * @param {JsfConfig} config - parser config + * @returns + */ +function getCustomPropertiesForField(fieldParams, config) { + return config?.customProperties?.[fieldParams.name]; +} + +/** + * Create field object using a compose function. + * If the fields has any customizations, it uses the _composeFieldExtra fn, otherwise it uses the inputTypeMap match + * @param {FieldParameters} fieldParams + * @param {Boolean} [hasCustomizations] + * @returns {Object} + */ +function getComposeFunctionForField(fieldParams, hasCustomizations) { + const composeFn = + inputTypeMap[fieldParams.inputType] || _composeFieldArbitraryClosure(fieldParams.inputType); + + if (hasCustomizations) { + return _composeFieldCustomClosure(composeFn); + } + return composeFn; +} + +/** + * Create field object using a compose function + * @param {FieldParameters} fieldParams - field parameters + * @param {JsfConfig} config - parser config + * @returns {Object} field object + */ +function buildField(fieldParams, config, scopedJsonSchema) { + const customProperties = getCustomPropertiesForField(fieldParams, config); + const composeFn = getComposeFunctionForField(fieldParams, !!customProperties); + + const yupSchema = buildYupSchema(fieldParams, config); + const calculateConditionalFieldsClosure = + fieldParams.isDynamic && calculateConditionalProperties(fieldParams, customProperties); + + const calculateCustomValidationPropertiesClosure = calculateCustomValidationProperties( + fieldParams, + customProperties + ); + + const hasCustomValidations = + !!customProperties && + size(pick(customProperties, SUPPORTED_CUSTOM_VALIDATION_FIELD_PARAMS)) > 0; + + const finalFieldParams = { + // invalid attribute cleanup + ...removeInvalidAttributes(fieldParams), + // calculateConditionalProperties function if needed + ...(!!calculateConditionalFieldsClosure && { + calculateConditionalProperties: calculateConditionalFieldsClosure, + }), + // calculateCustomValidationProperties function if needed + ...(hasCustomValidations && { + calculateCustomValidationProperties: calculateCustomValidationPropertiesClosure, + }), + // field customization properties + ...(customProperties && { fieldCustomization: customProperties }), + // base schema + schema: yupSchema(), + scopedJsonSchema, + }; + + return composeFn(finalFieldParams); +} + +/** + * Builds fields represented in the JSON-schema + * + * @param {Object} scopedJsonSchema - The json schema for this scope/layer, as it's recursive through fieldsets. + * @param {JsfConfig} config - JSON-schema-form config + * @returns {ParserFields} ParserFields + */ +function getFieldsFromJSONSchema(scopedJsonSchema, config) { + if (!scopedJsonSchema) { + // NOTE: other type of verifications might be needed. + return []; + } + + const fieldParamsList = convertJSONSchemaPropertiesToFieldParameters(scopedJsonSchema, config); + + applyFieldsDependencies(fieldParamsList, scopedJsonSchema); + + const fields = []; + + fieldParamsList.forEach((fieldParams) => { + if (fieldParams.inputType === 'group-array') { + const groupArrayItems = convertJSONSchemaPropertiesToFieldParameters(fieldParams.items); + const groupArrayFields = groupArrayItems.map((groupArrayItem) => { + groupArrayItem.nameKey = groupArrayItem.name; + const customProperties = null; // getCustomPropertiesForField(fieldParams, config); // TODO later support in group-array + const composeFn = getComposeFunctionForField(groupArrayItem, !!customProperties); + return composeFn(groupArrayItem); + }); + + fieldParams.nameKey = fieldParams.name; + + fieldParams.nthFieldGroup = { + name: fieldParams.name, + label: fieldParams.label, + description: fieldParams.description, + fields: () => groupArrayFields, + addFieldText: fieldParams.addFieldText, + }; + + buildField(fieldParams, config, scopedJsonSchema).forEach((groupField) => { + fields.push(groupField); + }); + } else { + fields.push(buildField(fieldParams, config, scopedJsonSchema)); + } + }); + + return fields; +} + +/** + * Generates the Headless form based on the provided JSON schema + * + * @param {Object} jsonSchema - JSON Schema + * @param {JsfConfig} customConfig - Config + */ +export function createHeadlessForm(jsonSchema, customConfig = {}) { + const config = { + strictInputType: true, + ...customConfig, + }; + + try { + const fields = getFieldsFromJSONSchema(jsonSchema, config); + + const handleValidation = handleValuesChange(fields, jsonSchema, config); + + updateFieldsProperties(fields, getPrefillValues(fields, config.initialValues), jsonSchema); + + return { + fields, + handleValidation, + isError: false, + }; + } catch (error) { + console.error('JSON Schema invalid!', error); + return { + fields: [], + isError: true, + error, + }; + } +} diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 00000000..9d2047c1 --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,577 @@ +import get from 'lodash/get'; +import isNil from 'lodash/isNil'; +import omit from 'lodash/omit'; +import omitBy from 'lodash/omitBy'; +import set from 'lodash/set'; +import { lazy } from 'yup'; + +import { supportedTypes, getInputType } from './internals/fields'; +import { pickXKey } from './internals/helpers'; +import { containsHTML, hasProperty, wrapWithSpan } from './utils'; +import { buildCompleteYupSchema, buildYupSchema } from './yupSchema'; + +/** + * @typedef {import('./createHeadlessForm').FieldParameters} FieldParameters + * @typedef {import('./createHeadlessForm').FieldValues} FieldValues + * @typedef {import('./createHeadlessForm').YupErrors} YupErrors + * @typedef {import('./createHeadlessForm').JsfConfig} JsfConfig + */ + +function hasType(type, typeName) { + return Array.isArray(type) + ? type.includes(typeName) // eg ["string", "null"] (optional field) + : type === typeName; // eg "string" +} + +/** + * Returns the field with the provided name + * @param {String} fieldName - name of the field + * @param {Object[]} fields - form fields + * @returns + */ +function getField(fieldName, fields) { + return fields.find(({ name }) => name === fieldName); +} + +/** + * Builds a Yup schema based on the provided field and validates it against the supplied value + * @param {Object} field + * @param {any} value + * @returns + */ +function validateFieldSchema(field, value) { + const validator = buildYupSchema(field); + return validator().isValidSync(value); +} + +/** + * Compares a form value with a `const` value from the JSON-schema. It does so by comparing the string version + * of both values to ensure that there are no type mismatches. + * + * @param {any} formValue - current form value + * @param {any} schemaValue - value specified in the schema + * @returns {Boolean} + */ +function compareFormValueWithSchemaValue(formValue, schemaValue) { + // If the value is a number, we can use it directly, otherwise we need to + // fallback to undefined since JSON-schemas empty values come represented as null + const currentPropertyValue = + typeof schemaValue === 'number' ? schemaValue : schemaValue || undefined; + // We're using the stringified version of both values since numeric values from forms come represented as Strings. + // By doing this, we're sure that we're comparing the same type. + return String(formValue) === String(currentPropertyValue); +} + +/** + * Checks if a "IF" condition matches given the current form state + * @param {Object} node - JSON schema node + * @param {Object} formValues - form state + * @returns {Boolean} + */ +function checkIfConditionMatches(node, formValues, formFields) { + return Object.keys(node.if.properties).every((name) => { + const currentProperty = node.if.properties[name]; + const value = formValues[name]; + const hasEmptyValue = + typeof value === 'undefined' || + // NOTE: This is a "Remote API" dependency, as empty fields are sent as "null". + value === null; + const hasIfExplicit = node.if.required?.includes(name); + + if (hasEmptyValue && !hasIfExplicit) { + // A property with empty value in a "if" will always match (lead to "then"), + // even if the actual conditional isn't true. Unless it's explicit in the if.required. + // WRONG:: if: { properties: { foo: {...} } } + // CORRECT:: if: { properties: { foo: {...} }, required: ['foo'] } + // Check MR !14408 for further explanation about the official specs + // https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else + return true; + } + + if (hasProperty(currentProperty, 'const')) { + return compareFormValueWithSchemaValue(value, currentProperty.const); + } + + if (currentProperty.contains?.pattern) { + // TODO: remove this || operation once https://gitlab.com/remote-com/employ-starbase/dragon/-/merge_requests/4098 + // is merged and transformValue does not run for the parser anymore + const formValue = value || []; + + // Making sure the form value type matches the expected type (array) when theres' a "contains" condition + if (Array.isArray(formValue)) { + const pattern = new RegExp(currentProperty.contains.pattern); + return (value || []).some((item) => pattern.test(item)); + } + } + + if (currentProperty.enum) { + return currentProperty.enum.includes(value); + } + + const { inputType } = getField(name, formFields); + + return validateFieldSchema({ ...currentProperty, inputType, required: true }, value); + }); +} + +/** + * Checks if the provided field has a value (array with positive length or truthy value) + * + * @param {String|number|Array} fieldValue form field value + * @return {Boolean} + */ +export function isFieldFilled(fieldValue) { + return Array.isArray(fieldValue) ? fieldValue.length > 0 : !!fieldValue; +} + +/** + * Finds first dependency that matches the current state of the form + * @param {[Object]} nodes - JSON schema nodes that the current field depends on + * @return {Object} + */ +export function findFirstAnyOfMatch(nodes, formValues) { + // if no match is found, consider the first node as the fallback + return ( + nodes.find(({ required }) => + required?.some((fieldName) => isFieldFilled(formValues[fieldName])) + ) || nodes[0] + ); +} + +/** + * Get initial values for sub fields within fieldsets + * @param {Object} field The form field + * @param {String=} parentFieldKeyPath The path to the parent field using dot-notation + * @returns {Object} The initial values for a fieldset + */ +function getPrefillSubFieldValues(field, defaultValues, parentFieldKeyPath) { + let initialValue = defaultValues ?? {}; + let fieldKeyPath = field.name; + + if (parentFieldKeyPath) { + fieldKeyPath = fieldKeyPath ? `${parentFieldKeyPath}.${fieldKeyPath}` : parentFieldKeyPath; + } + + const subFields = field.fields; + + if (Array.isArray(subFields)) { + const subFieldValues = {}; + + subFields.forEach((subField) => { + Object.assign( + subFieldValues, + getPrefillSubFieldValues(subField, initialValue[field.name], fieldKeyPath) + ); + }); + + if (field.inputType === supportedTypes.FIELDSET && field.valueGroupingDisabled) { + Object.assign(initialValue, subFieldValues); + } else { + initialValue[field.name] = subFieldValues; + } + } else { + // getDefaultValues and getPrefillSubFieldValues have a circluar dependency, resulting in one having to be used before defined. + // As function declarations are hoisted this should not be a problem. + // eslint-disable-next-line no-use-before-define + initialValue = getPrefillValues([field], initialValue); + } + + return initialValue; +} + +export function getPrefillValues(fields, initialValues = {}) { + // loop over fields array + // if prop does not exit in the initialValues object, + // pluck off the name and value props and add it to the initialValues object; + + fields.forEach((field) => { + const fieldName = field.name; + + switch (field.type) { + case supportedTypes.GROUP_ARRAY: { + initialValues[fieldName] = initialValues[fieldName]?.map((subFieldValues) => + getPrefillValues(field.fields(), subFieldValues) + ); + break; + } + case supportedTypes.FIELDSET: { + const subFieldValues = getPrefillSubFieldValues(field, initialValues); + Object.assign(initialValues, subFieldValues); + break; + } + + default: { + if (!initialValues[fieldName]) { + initialValues[fieldName] = field.default; + } + break; + } + } + }); + + return initialValues; +} + +/** + * Updates field properties based on the current JSON-schema node and the required fields + * + * @param {Object} field - field object + * @param {Set} requiredFields - required fields at the current point in the schema + * @param {Object} node - JSON-schema node + * @returns + */ +function updateField(field, requiredFields, node, formValues) { + // If there was an error building the field, it might not exist in the form even though + // it can be mentioned in the schema so we return early in that case + if (!field) { + return; + } + + const fieldIsRequired = requiredFields.has(field.name); + + // Update visibility + if (node.properties && hasProperty(node.properties, field.name)) { + // Field visibility can be controlled via the "properties" object: + // - if the field is marked as "false", it should be removed from the form + // - otherwise ("true" or object stating updated properties) it should be visible in the form + field.isVisible = !!node.properties[field.name]; + } + + // If field is required, it needs to be visible + if (fieldIsRequired) { + field.isVisible = true; + } + + const updateValues = (fieldValues) => + Object.entries(fieldValues).forEach(([key, value]) => { + // some values (eg "schema") are a function, so we need to call it here + field[key] = typeof value === 'function' ? value() : value; + + if (key === 'value') { + // The value of the field should not be driven by the json-schema, + // unless it's a read-only field + // If the readOnly property has changed, use that updated value, + // otherwise use the start value of the property + const readOnlyPropertyWasUpdated = typeof fieldValues.readOnly !== 'undefined'; + const isReadonlyByDefault = field.readOnly; + const isReadonly = readOnlyPropertyWasUpdated ? fieldValues.readOnly : isReadonlyByDefault; + + // Needs field.type check because otherwise checkboxes will have an initial + // value of "true" when they should be not checked. + // https://remote-com.slack.com/archives/C02HTN0LY02/p1647972907771519 for full context. This maybe be a + // TODO: to find a better solution here + if (!isReadonly && (value === null || field.inputType === 'checkbox')) { + // Note: doing an early return does not work, we need to reset the value + // so that formik takes charge of setting the value correctly + field.value = undefined; + } + } + }); + + // If field has a calculateConditionalProperties closure, run it and update the field properties + if (field.calculateConditionalProperties) { + const newFieldValues = field.calculateConditionalProperties(fieldIsRequired, node); + updateValues(newFieldValues); + } + + if (field.calculateCustomValidationProperties) { + const newFieldValues = field.calculateCustomValidationProperties( + fieldIsRequired, + node, + formValues + ); + updateValues(newFieldValues); + } +} + +/** + * Processes a JSON schema node by: + * - checking which fields are required (and adding them to a returned set) + * - (if there's an "if" conditional) checking which branch should be processed further + * - (if there's an "anyOf" operation) updating the items accordingly + * - (if there's an "allOf" operation) processing each field recursively + * - updating field parameters when needed + * + * @param {Object} node - JSON schema node + * @param {Object} formValues - form stater + * @param {Object[]} formFields - array of form fields + * @param {Set} accRequired - set of required field names gathered by traversing the tree + * @returns {Object} + */ +function processNode(node, formValues, formFields, accRequired = new Set()) { + // Set initial required fields + const requiredFields = new Set(accRequired); + + // Go through the node properties definition and update each field accordingly + Object.keys(node.properties ?? []).forEach((fieldName) => { + const field = getField(fieldName, formFields); + updateField(field, requiredFields, node, formValues); + }); + + // Update required fields based on the `required` property and mutate node if needed + node.required?.forEach((fieldName) => { + requiredFields.add(fieldName); + updateField(getField(fieldName, formFields), requiredFields, node, formValues); + }); + + if (node.if) { + const matchesCondition = checkIfConditionMatches(node, formValues, formFields); + if (matchesCondition && node.then) { + const { required: branchRequired } = processNode( + node.then, + formValues, + formFields, + requiredFields + ); + + branchRequired.forEach((field) => requiredFields.add(field)); + } else if (node.else) { + const { required: branchRequired } = processNode( + node.else, + formValues, + formFields, + requiredFields + ); + branchRequired.forEach((field) => requiredFields.add(field)); + } + } + + if (node.anyOf) { + const firstMatchOfAnyOf = findFirstAnyOfMatch(node.anyOf, formValues); + firstMatchOfAnyOf.required?.forEach((fieldName) => { + requiredFields.add(fieldName); + }); + + node.anyOf.forEach(({ required = [] }) => { + required.forEach((fieldName) => { + const field = getField(fieldName, formFields); + updateField(field, requiredFields, node, formValues); + }); + }); + } + + if (node.allOf) { + node.allOf + .map((allOfNode) => processNode(allOfNode, formValues, formFields, requiredFields)) + .forEach(({ required: allOfItemRequired }) => { + allOfItemRequired.forEach(requiredFields.add, requiredFields); + }); + } + + if (node.properties) { + Object.entries(node.properties).forEach(([name, nestedNode]) => { + const inputType = getInputType(nestedNode); + if (inputType === supportedTypes.FIELDSET) { + // It's a fieldset, which might contain scoped conditions + processNode(nestedNode, formValues[name] || {}, getField(name, formFields).fields); + } + }); + } + + return { + required: requiredFields, + }; +} + +/** + * Clears field value if the field is removed from the form + * Note: we're doing this in order to avoid sending old values if a user filled a field that later + * is hidden from the form. + * @param {Object[]} fields - field collection + * @param {Object} formValues - form state + */ +function clearValuesIfNotVisible(fields, formValues) { + fields.forEach(({ isVisible = true, name, inputType, fields: nestedFields }) => { + if (!isVisible) { + // TODO I (Sandrina) think this doesn't work. I didn't find any test covering this scenario. Revisit later. + formValues[name] = null; + } + if (inputType === supportedTypes.FIELDSET && nestedFields && formValues[name]) { + clearValuesIfNotVisible(nestedFields, formValues[name]); + } + }); +} +/** + * Updates form fields properties based on the current form state and the JSON schema rules + * + * @param {Object[]} fields - list of fields from createHeadlessForm + * @param {Object} formValues - current values of the form + * @param {Object} jsonSchema - JSON schema object + */ +export function updateFieldsProperties(fields, formValues, jsonSchema) { + if (!jsonSchema?.properties) { + return; + } + processNode(jsonSchema, formValues, fields); + clearValuesIfNotVisible(fields, formValues); +} + +const notNullOption = (opt) => opt.const !== null; + +function getFieldOptions(node, presentation) { + function convertToOptions(nodeOptions) { + return nodeOptions.filter(notNullOption).map(({ title, const: cons, ...item }) => ({ + label: title, + value: cons, + ...item, + })); + } + + /** @deprecated - takes precendence in case a JSON Schema still has deprecated options */ + if (presentation.options) { + return presentation.options; + } + + // it's similar to inputType=radio + if (node.oneOf) { + // Do not do if(hasType("string")) because a JSON Schema does not need it + // necessarily to be considered a valid json schema. + return convertToOptions(node.oneOf); + } + + // it's similar to inputType=select multiple + if (node.items?.anyOf) { + return convertToOptions(node.items.anyOf); + } + + return null; +} + +/** + * Extracts relevant field parameters from a JSON-schema node + * + * @param {Object} schemaNode - JSON-schema node + * @returns {FieldParameters} + */ +export function extractParametersFromNode(schemaNode) { + if (!schemaNode) { + return {}; + } + + const presentation = pickXKey(schemaNode, 'presentation') ?? {}; + const errorMessage = pickXKey(schemaNode, 'errorMessage') ?? {}; + + const node = omit(schemaNode, ['x-jsf-presentation', 'presentation']); + + const description = presentation?.description || node.description; + const statementDescription = containsHTML(presentation.statement?.description) + ? wrapWithSpan(presentation.statement.description, { class: 'jsf-statement' }) + : presentation.statement?.description; + + return omitBy( + { + label: node.title, + readOnly: node.readOnly, + ...(node.deprecated && { + deprecated: { + description: presentation.deprecated?.description, + // @TODO/@IDEA These might be useful down the road :thinking: + // version: presentation.deprecated.version, // e.g. "1.1" + // replacement: presentation.deprecated.replacement, // e.g. ['contract_duration_type'] + }, + }), + pattern: node.pattern, + options: getFieldOptions(node, presentation), + items: node.items, + maxLength: node.maxLength, + minLength: node.minLength, + minimum: node.minimum, + maximum: node.maximum, + maxFileSize: node.maxFileSize, // @deprecated in favor of presentation.maxFileSize + default: node.default, + // Checkboxes conditions + // — For checkboxes that only accept one value (string) + ...(presentation?.inputType === 'checkbox' && { checkboxValue: node.const }), + // - For checkboxes with boolean value + ...(presentation?.inputType === 'checkbox' && + node.type === 'boolean' && { + // true is what describes this checkbox as a boolean, regardless if its required or not + checkboxValue: true, + }), + ...(hasType(node.type, 'array') && { + multiple: true, + }), + + // Handle [name].presentation + ...presentation, + description: containsHTML(description) + ? wrapWithSpan(description, { + class: 'jsf-description', + }) + : description, + extra: containsHTML(presentation.extra) + ? wrapWithSpan(presentation.extra, { class: 'jsf-extra' }) + : presentation.extra, + statement: presentation.statement && { + ...presentation.statement, + description: statementDescription, + }, + // Support scoped conditions (fieldsets) + if: node.if, + then: node.then, + else: node.else, + anyOf: node.anyOf, + allOf: node.allOf, + errorMessage, + }, + isNil + ); +} + +/** + * Convert Yup errors mapped to the fields + * @example { name: "Required field.", age: "Must be bigger than 5." } + * note: This was copied from Formik source code: https://github.com/jaredpalmer/formik/blob/b9cc2536a1edb9f2d69c4cd20ecf4fa0f8059ade/packages/formik/src/Formik.tsx + */ +export function yupToFormErrors(yupError) { + if (!yupError) { + return yupError; + } + + const errors = {}; + + if (yupError.inner) { + if (yupError.inner.length === 0) { + return set(errors, yupError.path, yupError.message); + } + yupError.inner.forEach((err) => { + if (!get(errors, err.path)) { + set(errors, err.path, err.message); + } + }); + } + return errors; +} + +/** + * High order function to update the fields and validate them based on given values. + * Validate fields with Yup lazy. + * @param {Object[]} fields + * @param {Object} jsonSchema + * @param {JsfConfig} config - jsf config + * @returns {Function(values: Object): { YupError: YupObject, formErrors: Object }} Callback that returns Yup errors + */ +export const handleValuesChange = (fields, jsonSchema, config) => (values) => { + updateFieldsProperties(fields, values, jsonSchema); + + const lazySchema = lazy(() => buildCompleteYupSchema(fields, config)); + let errors; + + try { + lazySchema.validateSync(values, { + abortEarly: false, + }); + } catch (err) { + if (err.name === 'ValidationError') { + errors = err; + } else { + /* eslint-disable-next-line no-console */ + console.warn(`Warning: An unhandled error was caught during validationSchema`, err); + } + } + + return { + yupError: errors, + formErrors: yupToFormErrors(errors), + }; +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..983c5643 --- /dev/null +++ b/src/index.js @@ -0,0 +1 @@ +export { createHeadlessForm } from './createHeadlessForm'; diff --git a/src/internals/fields.js b/src/internals/fields.js new file mode 100644 index 00000000..02967833 --- /dev/null +++ b/src/internals/fields.js @@ -0,0 +1,377 @@ +/* eslint-disable no-underscore-dangle */ + +import { getFieldDescription, pickXKey } from './helpers'; + +/** + * @typedef {import('../createHeadlessForm').FieldParameters} FieldParameters + */ + +/** + * @typedef {import('../createHeadlessForm').FieldCustomization} FieldCustomization + */ + +/* From https://json-schema.org/understanding-json-schema/reference/type.html */ +const jsonTypes = { + STRING: 'string', + NUMBER: 'number', + INTEGER: 'integer', + OBJECT: 'object', + ARRAY: 'array', + BOOLEAN: 'boolean', + NULL: 'null', +}; + +export const supportedTypes = { + TEXT: 'text', + NUMBER: 'number', + SELECT: 'select', + FILE: 'file', + RADIO: 'radio', + GROUP_ARRAY: 'group-array', + EMAIL: 'email', + DATE: 'date', + CHECKBOX: 'checkbox', + FIELDSET: 'fieldset', +}; + +const jsonTypeToInputType = { + [jsonTypes.STRING]: ({ oneOf, format }) => { + if (format === 'email') return supportedTypes.EMAIL; + if (format === 'date') return supportedTypes.DATE; + if (format === 'data-url') return supportedTypes.FILE; + if (oneOf) return supportedTypes.RADIO; + return supportedTypes.TEXT; + }, + [jsonTypes.NUMBER]: () => supportedTypes.NUMBER, + [jsonTypes.INTEGER]: () => supportedTypes.NUMBER, + [jsonTypes.OBJECT]: () => supportedTypes.FIELDSET, + [jsonTypes.ARRAY]: ({ items }) => { + if (items.properties) return supportedTypes.GROUP_ARRAY; + return supportedTypes.SELECT; + }, + [jsonTypes.BOOLEAN]: () => supportedTypes.CHECKBOX, +}; + +/** + * @param {object} fieldProperties - any JSON schema field + * @param {boolean=} strictInputType - From config.strictInputType + * @param {name=} name - Field id (unique name) + * @returns {keyof supportedTypes} + */ +export function getInputType(fieldProperties, strictInputType, name) { + const presentation = pickXKey(fieldProperties, 'presentation') ?? {}; + const presentationInputType = presentation?.inputType; + + if (presentationInputType) { + return presentationInputType; + } + + if (strictInputType) { + throw Error(`Strict error: Missing inputType to field "${name || fieldProperties.title}". +You can fix the json schema or skip this error by calling createHeadlessForm(schema, { strictInputType: false })`); + } + + if (!fieldProperties.type) { + if (fieldProperties.items?.properties) { + return supportedTypes.GROUP_ARRAY; + } + if (fieldProperties.properties) { + return supportedTypes.SELECT; + } + return jsonTypeToInputType[jsonTypes.STRING](fieldProperties); + } + + return jsonTypeToInputType[fieldProperties.type]?.(fieldProperties); +} + +/** + * Return base attributes needed for a file field. + * @param {Object} attrs + * @param {String} attrs.name - field name + * @param {String} attrs.label - field label + * @param {String} attrs.description - field description + * @param {Boolean} attrs.required - field required + * @param {String} attrs.accept - comma separated supported types + * @return {Object} + */ +export function _composeFieldFile({ name, label, description, accept, required = true, ...attrs }) { + return { + type: supportedTypes.FILE, + name, + label, + description, + required, + accept, + ...attrs, + }; +} + +/** + * Return base attributes needed for a text field. + * @param {Object} attrs + * @param {String} attrs.name - field name + * @param {String} attrs.label - field label + * @param {String} attrs.description - field description + * @param {Boolean} attrs.required - field required + * @return {Object} + */ +export function _composeFieldText({ name, label, description, required = true, ...attrs }) { + return { + type: supportedTypes.TEXT, + name, + label, + description, + required, + ...attrs, + }; +} + +/** + * Return base attributes needed for a email field. + * @param {Object} attrs + * @param {String} attrs.name - field name + * @param {String} attrs.label - field label + * @param {Boolean} attrs.required - field required + * @return {Object} + */ +export function _composeFieldEmail({ name, label, required = true, ...attrs }) { + return { + type: supportedTypes.EMAIL, + name, + label, + required, + ...attrs, + }; +} + +/** + * Return base attributes needed for a number field. + * @param {Object} attrs + * @param {String} attrs.name - field name + * @param {String} attrs.label - field label + * @param {Boolean} attrs.percentage - field percentage + * @param {Boolean} attrs.required - field required + * @return {Object} + */ +export function _composeFieldNumber({ + name, + label, + percentage = false, + required = true, + minimum, + maximum, + ...attrs +}) { + let minValue = minimum; + let maxValue = maximum; + + if (percentage) { + minValue = minValue ?? 0; + maxValue = maxValue ?? 100; + } + + return { + type: supportedTypes.NUMBER, + name, + label, + percentage, + required, + minimum: minValue, + maximum: maxValue, + ...attrs, + }; +} + +/** + * Return base attributes needed for a date field. + * @param {Object} attrs + * @param {String} attrs.name - field name + * @param {String} attrs.label - field label + * @param {Boolean} attrs.required - field required + * @return {Object} + */ +export function _composeFieldDate({ name, label, required = true, ...attrs }) { + return { + type: supportedTypes.DATE, + name, + label, + required, + ...attrs, + }; +} + +/** + * Return base attributes needed for a radio field. + * @param {Object} attrs + * @param {String} attrs.name - field name + * @param {String} attrs.label - field label + * @param {Object[]} attrs.options - radio options + * @param {Boolean} attrs.required - field required + * @return {Object} + */ +export function _composeFieldRadio({ name, label, options, required = true, ...attrs }) { + return { + type: supportedTypes.RADIO, + name, + label, + options, + required, + ...attrs, + }; +} + +/** + * Return base attributes needed for a select field. + * @param {Object} attrs + * @param {String} attrs.name - field name + * @param {String} attrs.label - field label + * @param {{ label: String, value: String }[]} attrs.options - select options - array of objects + * @param {Boolean} attrs.required - field required + * @return {Object} + */ +export function _composeFieldSelect({ name, label, options, required = true, ...attrs }) { + return { + type: supportedTypes.SELECT, + name, + label, + options, + required, + ...attrs, + }; +} + +/** + * Return attributes needed for a group array field. + * @param {Object} attributes + * @param {String} attributes.name Field's name + * @param {Boolean} attributes.required Field required + * @param {String} attributes.addFieldText Label to be used on the add field button + * @param {Object} attributes.nthFieldGroup + * @param {String} attributes.nthFieldGroup.name Field group's name + * @param {String} attributes.nthFieldGroup.label Field group's label + * @param {Function} attributes.nthFieldGroup.fields Function that returns an array of dynamicForm fields. + * @return {Array} + */ +export function _composeNthFieldGroup({ name, label, required, nthFieldGroup, ...attrs }) { + return [ + { + ...nthFieldGroup, + type: supportedTypes.GROUP_ARRAY, + name, + label, + required, + ...attrs, + }, + ]; +} + +/** + * Return base attributes needed for an ack field + * @param {Object} attrs + * @param {String} attrs.name - field name + * @param {String} attrs.label - field label + * @param {String} attrs.description - field description + * @param {String} attrs.default - specifies a default value for the checkbox + * @param {String} attrs.checkboxValue - value that's set to the form when the input is checked + * @return {Object} + */ +export function _composeFieldCheckbox({ + required = true, + name, + label, + description, + default: defaultValue, + checkboxValue, + ...attrs +}) { + return { + type: supportedTypes.CHECKBOX, + required, + name, + label, + description, + checkboxValue, + ...(defaultValue && { default: defaultValue }), + ...attrs, + }; +} + +/** + * Return attributes needed for a fieldset. + * @param {Object} attributes + * @param {String} attributes.name + * @param {String} attributes.label + * @param {Array} attributes.fields + * @param {"default" | "focused"} [attributes.variant] + * @return {Array} + */ +export function _composeFieldset({ name, label, fields, variant, ...attrs }) { + return { + type: supportedTypes.FIELDSET, + name, + label, + fields, + variant, + ...attrs, + }; +} + +/** + * Return attributes needed for an arbitrary field. + * @param {Object} attrs + * @param {String} attrs.name + * @param {String} attrs.label + * @return {Array} + */ +export const _composeFieldArbitraryClosure = (inputType) => (attrs) => ({ + type: inputType, + ...attrs, +}); + +export const inputTypeMap = { + text: _composeFieldText, + select: _composeFieldSelect, + radio: _composeFieldRadio, + date: _composeFieldDate, + number: _composeFieldNumber, + 'group-array': _composeNthFieldGroup, + fieldset: _composeFieldset, + file: _composeFieldFile, + email: _composeFieldEmail, + checkbox: _composeFieldCheckbox, +}; + +/** + * Returns an input compose function for a customized field + * @param {String} type - inputType + */ +export function _composeFieldCustomClosure(defaultComposeFn) { + /** + * @param {FieldParameters & {fieldCustomization: FieldCustomization}} params - attributes + * @returns {Object} + */ + return ({ fieldCustomization, ...attrs }) => { + const { description, ...restFieldCustomization } = fieldCustomization; + const fieldDescription = getFieldDescription(attrs, fieldCustomization); + const { nthFieldGroup, ...restAttrs } = attrs; + const commonAttrs = { + ...restAttrs, + ...restFieldCustomization, + ...fieldDescription, + }; + + if (attrs.inputType === supportedTypes.GROUP_ARRAY) { + return [ + { + ...nthFieldGroup, + ...commonAttrs, + }, + ]; + } + + return { + ...defaultComposeFn(attrs), + ...commonAttrs, + }; + }; +} diff --git a/src/internals/helpers.js b/src/internals/helpers.js new file mode 100644 index 00000000..ff7f6e46 --- /dev/null +++ b/src/internals/helpers.js @@ -0,0 +1,56 @@ +/** + * @typedef {Object} Node + * @typedef {Object} CustomProperties + * @typedef {Object} FieldDescription + */ + +import merge from 'lodash/fp/merge'; +import get from 'lodash/get'; +import isEmpty from 'lodash/isEmpty'; +import isFunction from 'lodash/isFunction'; + +/** + * Returns the object from the JSON-schema node using the key. + * + * @param {Object} node - JSON-schema node + * @param {String} key - JSON-schema key name + * @returns {Object} + */ +export function pickXKey(node, key) { + const deprecatedKeys = ['presentation', 'errorMessage']; + + return get(node, `x-jsf-${key}`, deprecatedKeys.includes(key) ? node?.[key] : undefined); +} + +/** + * Use the field description from CustomProperties if it exists. + * @param {Node} node - Json-schema node + * @param {CustomProperties} customProperties + * @return {FieldDescription} + */ +export function getFieldDescription(node, customProperties = {}) { + const nodeDescription = node?.description + ? { + description: node.description, + } + : {}; + + const customDescription = customProperties?.description + ? { + description: isFunction(customProperties.description) + ? customProperties.description(node?.description, { + ...node, + ...customProperties, + }) + : customProperties.description, + } + : {}; + + const nodePresentation = pickXKey(node, 'presentation'); + + const presentation = !isEmpty(nodePresentation) && { + presentation: { ...nodePresentation, ...customDescription }, + }; + + return merge(nodeDescription, { ...customDescription, ...presentation }); +} diff --git a/src/internals/index.js b/src/internals/index.js new file mode 100644 index 00000000..8dce8655 --- /dev/null +++ b/src/internals/index.js @@ -0,0 +1,2 @@ +export * from './fields'; +export * from './helpers'; diff --git a/src/tests/createHeadlessForm.customValidations.test.jsx b/src/tests/createHeadlessForm.customValidations.test.jsx new file mode 100644 index 00000000..e77dae04 --- /dev/null +++ b/src/tests/createHeadlessForm.customValidations.test.jsx @@ -0,0 +1,799 @@ +import merge from 'lodash/fp/merge'; + +import { createHeadlessForm } from '../createHeadlessForm'; + +import { JSONSchemaBuilder, mockFieldset, mockRadioInput } from './helpers'; +import { mockMoneyInput } from './helpers.custom'; + +function friendlyError({ formErrors }) { + // destruct the formErrors directly + return formErrors; +} + +export const mockNumberInput = { + title: 'Tabs', + description: 'How many open tabs do you have?', + 'x-jsf-presentation': { + inputType: 'number', + }, + minimum: 5, + maximum: 30, + type: 'number', +}; + +export const mockNumberInputDeprecatedPresentation = { + title: 'Tabs', + description: 'How many open tabs do you have?', + presentation: { + inputType: 'number', + }, + minimum: 5, + maximum: 30, + type: 'number', +}; + +const schemaBasic = ({ newProperties, allOf } = {}) => + JSONSchemaBuilder() + .addInput( + merge( + { + parent_age: { ...mockNumberInput, maximum: 100 }, + child_age: mockNumberInput, + }, + newProperties + ) + ) + .setRequiredFields(['parent_age']) + .addAllOf(allOf || []) + .build(); + +const schemaWithConditional = ({ newProperties } = {}) => + JSONSchemaBuilder() + .addInput( + merge( + { + is_employee: mockRadioInput, + salary: { ...mockMoneyInput, minimum: 0 }, + bonus: { ...mockMoneyInput, minimum: 0 }, + }, + newProperties + ) + ) + .setRequiredFields(['is_employee', 'salary']) + .addAllOf([ + { + if: { + properties: { + is_employee: { + const: 'yes', + }, + }, + required: ['is_employee'], + }, + then: { + properties: { + salary: { + minimum: 100000, // 1000.00€ + }, + }, + required: ['bonus'], + }, + else: { + properties: { + salary: { + minimum: 0, // 0.00€ + }, + bonus: false, + }, + }, + }, + ]) + .build(); + +function validateFieldParams(fieldParams, newFieldParams) { + expect(newFieldParams).toHaveProperty('name', fieldParams.name); + expect(newFieldParams).toHaveProperty('label', fieldParams.title); + expect(newFieldParams).toHaveProperty('description', fieldParams.description); + + if (fieldParams.minimum) { + expect(newFieldParams).toHaveProperty('minimum', fieldParams.minimum); + } + if (fieldParams.maximum) { + expect(newFieldParams).toHaveProperty('maximum', fieldParams.maximum); + } +} + +function validateNumberParams(fieldParams, newFieldParams) { + validateFieldParams(fieldParams, newFieldParams); + expect(newFieldParams).toHaveProperty('inputType', 'number'); + expect(newFieldParams).toHaveProperty('jsonType', 'number'); +} + +function validateMoneyParams(fieldParams, newFieldParams) { + validateFieldParams(fieldParams, newFieldParams); + expect(newFieldParams).toHaveProperty('inputType', 'money'); + expect(newFieldParams).toHaveProperty('jsonType', 'integer'); +} + +function createScenario({ schema, config }) { + const form = createHeadlessForm(schema, config); + const validateForm = (vals) => friendlyError(form.handleValidation(vals)); + + return { + ...form, + validateForm, + }; +} + +beforeAll(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + // safety-check that every mocked validation is within the range + // eslint-disable-next-line no-console + expect(console.warn).not.toHaveBeenCalled(); +}); + +afterAll(() => { + // eslint-disable-next-line no-console + console.warn.mockRestore(); +}); + +describe('createHeadlessForm() - custom validations', () => { + describe('simple validation (eg maximum)', () => { + it('works as a number', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic(), + config: { + customProperties: { + child_age: { + maximum: 14, + }, + }, + }, + }); + + validateNumberParams({ ...mockNumberInput, name: 'child_age', maximum: 14 }, fields[1]); + + expect(validateForm({})).toEqual({ + parent_age: 'Required field', + }); + + expect(validateForm({ parent_age: 30, child_age: 15 })).toEqual({ + child_age: 'Must be smaller or equal to 14', + }); + + expect(validateForm({ parent_age: 30, child_age: 10 })).toBeUndefined(); + }); + + it('works as a function', () => { + // Friendly Scenario: child_age must be smaller than parent_age. + const { fields, validateForm } = createScenario({ + schema: schemaBasic(), + config: { + customProperties: { + child_age: { + maximum: (values, { maximum }) => values.parent_age || maximum, + }, + }, + }, + }); + + validateNumberParams( + { ...mockNumberInput, name: 'child_age', maximum: undefined }, + fields[1] + ); + + expect(validateForm({})).toEqual({ + parent_age: 'Required field', + }); + + expect(validateForm({ parent_age: 25, child_age: 26 })).toEqual({ + child_age: 'Must be smaller or equal to 25', + }); + expect(validateForm({ parent_age: 25, child_age: 20 })).toBeUndefined(); + }); + + it('works with minimum and maximum together', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic(), + config: { + customProperties: { + child_age: { + // dumb example: parents that are less than double the child age, + // the child must be between 20 and 29yo. + minimum: (values, { minimum }) => + values.parent_age < values.child_age * 3 ? 20 : minimum, + maximum: (values, { maximum }) => + values.parent_age < values.child_age * 3 ? 29 : maximum, + }, + }, + }, + }); + + validateNumberParams( + { ...mockNumberInput, name: 'child_age', minimum: 5, maximum: 30 }, + fields[1] + ); + + // Test the default validations + expect(validateForm({ parent_age: 50, child_age: 1 })).toEqual({ + child_age: 'Must be greater or equal to 5', + }); + expect(validateForm({ parent_age: 100, child_age: 31 })).toEqual({ + child_age: 'Must be smaller or equal to 30', + }); + + // Test the custom validations + expect(validateForm({ parent_age: 35, child_age: 19 })).toEqual({ + child_age: 'Must be greater or equal to 20', + }); + expect(validateForm({ parent_age: 40, child_age: 31 })).toEqual({ + child_age: 'Must be smaller or equal to 29', + }); + }); + + it('works with negative values', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic({ + newProperties: { + parent_age: { + minimum: -20, + maximum: -1, + }, + }, + }), + config: { + customProperties: { + parent_age: { + minimum: -15, + maximum: -5, + }, + }, + }, + }); + + validateNumberParams( + { ...mockNumberInput, name: 'parent_age', minimum: -15, maximum: -5 }, + fields[0] + ); + + expect(validateForm({})).toEqual({ + parent_age: 'Required field', + }); + + expect(validateForm({ parent_age: -20 })).toEqual({ + parent_age: 'Must be greater or equal to -15', + }); + + expect(validateForm({ parent_age: -4 })).toEqual({ + parent_age: 'Must be smaller or equal to -5', + }); + + expect(validateForm({ parent_age: -10 })).toBeUndefined(); + }); + + it('keeps original validation, given an empty validation', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic(), + config: { + customProperties: { + parent_age: {}, + }, + }, + }); + + validateNumberParams({ ...mockNumberInput, name: 'parent_age', maximum: 100 }, fields[0]); + + expect(validateForm({})).toEqual({ + parent_age: 'Required field', + }); + + expect(validateForm({ parent_age: 0 })).toEqual({ + parent_age: 'Must be greater or equal to 5', + }); + }); + + it('applies validation, when original does not exist', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic({ + newProperties: { + parent_age: { minimum: null, maximum: null }, + }, + }), + config: { + customProperties: { + parent_age: { + minimum: 1, + maximum: 20, + }, + }, + }, + }); + + validateNumberParams( + { ...mockNumberInput, minimum: 1, maximum: 20, name: 'parent_age' }, + fields[0] + ); + + expect(validateForm({})).toEqual({ + parent_age: 'Required field', + }); + + expect(validateForm({ parent_age: 0 })).toEqual({ + parent_age: 'Must be greater or equal to 1', + }); + + expect(validateForm({ parent_age: 21 })).toEqual({ + parent_age: 'Must be smaller or equal to 20', + }); + }); + }); + + describe('in fieldsets', () => { + it('applies custom validation in nested fields', () => { + const { fields, validateForm } = createScenario({ + schema: JSONSchemaBuilder() + .addInput({ + animal_age: mockNumberInput, + second_gen: { + ...mockFieldset, + properties: { + cub_age: mockNumberInput, + third_gen: { + ...mockFieldset, + properties: { + grandcub_age: mockNumberInput, + }, + }, + }, + }, + }) + .build(), + config: { + customProperties: { + animal_age: { + minimum: 24, + maximum: 28, + }, + second_gen: { + cub_age: { + minimum: 18, + maximum: 21, + }, + third_gen: { + grandcub_age: { + minimum: 10, + maximum: 15, + }, + }, + }, + }, + }, + }); + + const [animalField, secondGenField] = fields; + + // Assert custom validations + validateNumberParams( + { + ...mockNumberInput, + name: 'animal_age', + minimum: 24, + maximum: 28, + required: false, + }, + animalField + ); + validateNumberParams( + { + ...mockNumberInput, + name: 'cub_age', + minimum: 18, + maximum: 21, + required: false, + }, + secondGenField.fields[0] + ); + validateNumberParams( + { + ...mockNumberInput, + name: 'grandcub_age', + minimum: 10, + maximum: 15, + required: false, + }, + secondGenField.fields[1].fields[0] + ); + + // Assert minimum values + expect( + validateForm({ + animal_age: 1, + second_gen: { + cub_age: 1, + third_gen: { + grandcub_age: 1, + }, + }, + }) + ).toEqual({ + animal_age: 'Must be greater or equal to 24', + second_gen: { + cub_age: 'Must be greater or equal to 18', + third_gen: { + grandcub_age: 'Must be greater or equal to 10', + }, + }, + }); + + // Assert maximum values + expect( + validateForm({ + animal_age: 100, + second_gen: { + cub_age: 100, + third_gen: { + grandcub_age: 100, + }, + }, + }) + ).toEqual({ + animal_age: 'Must be smaller or equal to 28', + second_gen: { + cub_age: 'Must be smaller or equal to 21', + third_gen: { + grandcub_age: 'Must be smaller or equal to 15', + }, + }, + }); + }); + }); + + describe('in conditional fields', () => { + const { fields, validateForm } = createScenario({ + schema: schemaWithConditional(), + config: { + customProperties: { + bonus: { + maximum: (values, { maximum }) => ({ + maximum: values.salary ? values.salary * 2 : maximum, + 'x-jsf-errorMessage': { + maximum: `The bonus cannot be twice of the salary ${values.salary}.`, + }, + }), + }, + }, + }, + }); + + it('validates conditional visible field', () => { + // bonus fieldResult + validateMoneyParams( + { + ...mockMoneyInput, + name: 'bonus', + minimum: 0, + maximum: 500000, + required: false, + }, + fields[2] + ); + + // Basic path — the custom validation is triggered + expect( + validateForm({ + is_employee: 'yes', + salary: 150000, + bonus: 310000, + }) + ).toEqual({ bonus: 'The bonus cannot be twice of the salary 150000.' }); + + // The values are valid: + expect( + validateForm({ + is_employee: 'yes', + salary: 150000, + bonus: 20000, + }) + ).toBeUndefined(); + + expect(validateForm({ is_employee: 'yes', salary: 150000 })).toEqual({ + bonus: 'Required field', + }); + }); + + it('ignores validation to conditional hidden field', () => { + expect( + validateForm({ + is_employee: 'no', + salary: 150000, + bonus: 310000, + // NOTE/Unrelated-bug: Should it throw an error saying this + // "bonus" value is not expected? the native json schema spec throw an error... + }) + ).toBeUndefined(); + }); + + it('given an out-of-range validation, logs warning', () => { + expect( + validateForm({ + is_employee: 'yes', + salary: 300000, + bonus: 500100, + }) + ).toEqual({ + bonus: 'No more than €5000.00', + }); + + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenNthCalledWith( + 1, + 'Custom validation for bonus is not allowed because maximum:600000 is less strict than the original range: 0 to 500000' + ); + // eslint-disable-next-line no-console + console.warn.mockClear(); + }); + }); + + // TODO: delete after migration to x-jsf-errorMessage is completed + describe('with errorMessage (deprecated)', () => { + /* NOTE: We have 3 type of errors: + - original error: (created by json-schema-form) + - errorMessage: (declared on JSON Schema) + - customValidation.errorMessage: (declared on config) + */ + it('overrides original error conditionally', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic(), + config: { + customProperties: { + child_age: { + maximum: (values, { maximum }) => ({ + maximum: values.parent_age || maximum, + errorMessage: { + maximum: `The child cannot be older than the parent of ${values.parent_age} yo.`, + }, + }), + }, + }, + }, + }); + validateNumberParams( + { + ...mockNumberInput, + name: 'child_age', + minimum: 5, + maximum: 30, + }, + fields[1] + ); + + expect(validateForm({ parent_age: 18, child_age: 4 })).toEqual({ + child_age: 'Must be greater or equal to 5', // applies the original error message + }); + expect(validateForm({ parent_age: 18, child_age: 19 })).toEqual({ + child_age: 'The child cannot be older than the parent of 18 yo.', // applies the config.errorMessage + }); + }); + + it('overrides errorMessage conditionally', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic({ + newProperties: { + parent_age: { + maximum: 100, + }, + child_age: { + maximum: 40, + errorMessage: { + maximum: 'The child cannot be older than 40yo.', + }, + }, + }, + }), + config: { + customProperties: { + child_age: { + minimum: (values, { maximum }) => { + const minimumAge = values.parent_age / 2; + if ( + maximum > minimumAge && // prevent invalid out-of-range maximum + values.parent_age > values.child_age * 2 // parent is 2x as big as child age + ) { + return { + minimum: minimumAge, + errorMessage: { + minimum: `The child cannot be younger than half of the parent. Must be at least ${minimumAge}yo.`, + }, + }; + } + + return null; + }, + }, + }, + }, + }); + validateNumberParams( + { + ...mockNumberInput, + name: 'child_age', + minimum: 5, + maximum: 40, + }, + fields[1] + ); + + // applies the errorMessage by default + expect(validateForm({ parent_age: 50, child_age: 45 })).toEqual({ + child_age: 'The child cannot be older than 40yo.', + }); + // applies the config.errorMessage if it's triggered + expect(validateForm({ parent_age: 50, child_age: 10 })).toEqual({ + child_age: `The child cannot be younger than half of the parent. Must be at least 25yo.`, + }); + }); + }); + + describe('with x-jsf-errorMessage', () => { + /* NOTE: We have 3 type of errors: + - original error: (created by json-schema-form) + - x-jsf-errorMessage: (declared on JSON Schema) + - customValidation['x-jsf-errorMessage']: (declared on options) + */ + it('overrides original error conditionally', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic(), + config: { + customProperties: { + child_age: { + maximum: (values, { maximum }) => ({ + maximum: values.parent_age || maximum, + 'x-jsf-errorMessage': { + maximum: `The child cannot be older than the parent of ${values.parent_age} yo.`, + }, + }), + }, + }, + }, + }); + validateNumberParams( + { + ...mockNumberInput, + name: 'child_age', + minimum: 5, + maximum: 30, + }, + fields[1] + ); + + expect(validateForm({ parent_age: 18, child_age: 4 })).toEqual({ + child_age: 'Must be greater or equal to 5', // applies the original error message + }); + expect(validateForm({ parent_age: 18, child_age: 19 })).toEqual({ + child_age: 'The child cannot be older than the parent of 18 yo.', // applies the config.errorMessage + }); + }); + + it('overrides errorMessage conditionally', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic({ + newProperties: { + parent_age: { + maximum: 100, + }, + child_age: { + maximum: 40, + 'x-jsf-errorMessage': { + maximum: 'The child cannot be older than 40yo.', + }, + }, + }, + }), + config: { + customProperties: { + child_age: { + minimum: (values, { maximum }) => { + const minimumAge = values.parent_age / 2; + if ( + maximum > minimumAge && // prevent invalid out-of-range maximum + values.parent_age > values.child_age * 2 // parent is 2x as big as child age + ) { + return { + minimum: minimumAge, + 'x-jsf-errorMessage': { + minimum: `The child cannot be younger than half of the parent. Must be at least ${minimumAge}yo.`, + }, + }; + } + + return null; + }, + }, + }, + }, + }); + validateNumberParams( + { + ...mockNumberInput, + name: 'child_age', + minimum: 5, + maximum: 40, + }, + fields[1] + ); + + // applies the errorMessage by default + expect(validateForm({ parent_age: 50, child_age: 45 })).toEqual({ + child_age: 'The child cannot be older than 40yo.', + }); + // applies the config.errorMessage if it's triggered + expect(validateForm({ parent_age: 50, child_age: 10 })).toEqual({ + child_age: `The child cannot be younger than half of the parent. Must be at least 25yo.`, + }); + }); + }); + + describe('invalid validations', () => { + it('outside the schema range logs warning', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic(), + config: { + customProperties: { + parent_age: { + minimum: 0, + }, + }, + }, + }); + + validateNumberParams( + { ...mockNumberInput, minimum: 5, maximum: 100, name: 'parent_age' }, + fields[0] + ); + + // Keeps the default validation + expect(validateForm({ parent_age: 0 })).toEqual({ + parent_age: 'Must be greater or equal to 5', + }); + + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenNthCalledWith( + 1, + 'Custom validation for parent_age is not allowed because minimum:0 is less strict than the original range: 5 to 100' + ); + // eslint-disable-next-line no-console + console.warn.mockClear(); + }); + + it('null or undefined ignores validation', () => { + const { fields, validateForm } = createScenario({ + schema: schemaBasic(), + config: { + customProperties: { + parent_age: { + minimum: undefined, + maximum: null, + }, + }, + }, + }); + + // The original validation is kept + validateNumberParams( + { ...mockNumberInput, minimum: 5, maximum: 100, name: 'parent_age' }, + fields[0] + ); + + expect(validateForm({ parent_age: 0 })).toEqual({ + parent_age: 'Must be greater or equal to 5', + }); + + expect(validateForm({ parent_age: 200 })).toEqual({ + parent_age: 'Must be smaller or equal to 100', + }); + }); + }); +}); diff --git a/src/tests/createHeadlessForm.test.js b/src/tests/createHeadlessForm.test.js new file mode 100644 index 00000000..64cd3dfa --- /dev/null +++ b/src/tests/createHeadlessForm.test.js @@ -0,0 +1,3344 @@ +import isNil from 'lodash/isNil'; +import omitBy from 'lodash/omitBy'; +import { object } from 'yup'; + +import { createHeadlessForm } from '../createHeadlessForm'; + +import { + JSONSchemaBuilder, + schemaInputTypeText, + schemaInputTypeRadioDeprecated, + schemaInputTypeRadio, + schemaInputTypeRadioRequiredAndOptional, + schemaInputTypeSelectSoloDeprecated, + schemaInputTypeSelectSolo, + schemaInputTypeSelectMultipleDeprecated, + schemaInputTypeSelectMultiple, + schemaInputTypeSelectMultipleOptional, + schemaInputTypeNumber, + schemaInputTypeDate, + schemaInputTypeEmail, + schemaInputWithStatement, + schemaInputWithStatementDeprecated, + schemaInputTypeCheckbox, + schemaInputTypeCheckboxBooleans, + schemaWithOrderKeyword, + schemaWithPositionDeprecated, + schemaDynamicValidationConst, + schemaDynamicValidationMinimumMaximum, + schemaDynamicValidationMinLengthMaxLength, + schemaDynamicValidationContains, + schemaAnyOfValidation, + schemaWithoutInputTypes, + schemaWithoutTypes, + mockFileInput, + mockRadioCardInput, + mockRadioCardExpandableInput, + mockTextInput, + mockTextInputDeprecated, + mockNumberInput, + mockNumberInputWithPercentageAndCustomRange, + mockTextPatternInput, + mockTextMaxLengthInput, + mockFieldset, + mockNestedFieldset, + mockGroupArrayInput, + schemaFieldsetScopedCondition, + schemaWithConditionalPresentationProperties, + schemaWithConditionalReadOnlyProperty, + schemaWithWrongConditional, + schemaWithConditionalAcknowledgementProperty, + schemaInputTypeNumberWithPercentage, + schemaForErrorMessageSpecificity, + jsfConfigForErrorMessageSpecificity, +} from './helpers'; + +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; +} + +beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + expect(console.error).not.toHaveBeenCalled(); + console.error.mockRestore(); +}); + +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', () => { + it('support "text" field type', () => { + const { fields } = createHeadlessForm(schemaInputTypeText); + + expect(fields[0]).toMatchObject({ + description: 'The number of your national identification (max 10 digits)', + label: 'ID number', + name: 'id_number', + 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(true); // @BUG RMT-446 - cannot be a bool + expect(fieldValidator.isValidSync(1)).toBe(true); // @BUG RMT-446 - cannot be a number + expect(fieldValidator.isValidSync(0)).toBe(true); // @BUG RMT-446 - cannot be a number + + expect(() => fieldValidator.validateSync('')).toThrowError('Required field'); + }); + + it('supports both root level "description" and "x-jsf-presentation.description"', () => { + const resultsWithRootDescription = createHeadlessForm( + JSONSchemaBuilder() + .addInput({ + id_number: mockTextInput, + }) + .setRequiredFields(['id_number']) + .build() + ); + + expect(resultsWithRootDescription.fields[0].description).toMatch( + /the number of your national/i + ); + + const resultsWithPresentationDescription = createHeadlessForm( + JSONSchemaBuilder() + .addInput({ + id_number: { + ...mockTextInput, + 'x-jsf-presentation': { + inputType: 'text', + maskSecret: 2, + // should override the root level description + description: 'a different description with markup', + }, + }, + }) + .setRequiredFields(['id_number']) + .build() + ); + + expect(resultsWithPresentationDescription.fields[0].description).toMatch( + /a different description /i + ); + }); + + it('supports both root level "description" and "presentation.description" (deprecated)', () => { + const resultsWithRootDescription = createHeadlessForm( + JSONSchemaBuilder() + .addInput({ + id_number: mockTextInputDeprecated, + }) + .setRequiredFields(['id_number']) + .build() + ); + + expect(resultsWithRootDescription.fields[0].description).toMatch( + /the number of your national/i + ); + + const resultsWithPresentationDescription = createHeadlessForm( + JSONSchemaBuilder() + .addInput({ + id_number: { + ...mockTextInputDeprecated, + presentation: { + inputType: 'text', + maskSecret: 2, + // should override the root level description + description: 'a different description with markup', + }, + }, + }) + .setRequiredFields(['id_number']) + .build() + ); + + expect(resultsWithPresentationDescription.fields[0].description).toMatch( + /a different description /i + ); + }); + + it('support "select" field type @deprecated', () => { + const result = createHeadlessForm(schemaInputTypeSelectSoloDeprecated); + + expect(result).toMatchObject({ + fields: [ + { + 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', + }, + ], + }, + ], + }); + }); + it('support "select" field type', () => { + const result = createHeadlessForm(schemaInputTypeSelectSolo); + + const fieldSelect = result.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'); + }); + + 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, + }, + ], + }); + }); + + it('support "radio" field type @deprecated', () => { + const result = createHeadlessForm(schemaInputTypeRadioDeprecated); + + expect(result).toMatchObject({ + fields: [ + { + 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', + }, + ], + }); + + const fieldValidator = result.fields[0].schema; + expect(fieldValidator.isValidSync('yes')).toBe(true); + expect(() => fieldValidator.validateSync('')).toThrowError('Required field'); + }); + it('support "radio" field type', () => { + const result = createHeadlessForm(schemaInputTypeRadio); + + expect(result).toMatchObject({ + fields: [ + { + 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', + }, + ], + }); + + const fieldValidator = result.fields[0].schema; + expect(fieldValidator.isValidSync('yes')).toBe(true); + expect(() => fieldValidator.validateSync('')).toThrowError('Required field'); + }); + + it('support "radio" optional field', () => { + const result = createHeadlessForm(schemaInputTypeRadioRequiredAndOptional); + + expect(result).toMatchObject({ + fields: [ + {}, + { + 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', + }, + ], + }); + + const fieldValidator = result.fields[0].schema; + expect(fieldValidator.isValidSync('yes')).toBe(true); + expect(() => fieldValidator.validateSync('')).toThrowError('Required field'); + }); + + 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 result = createHeadlessForm(schemaInputTypeDate); + + expect(result).toMatchObject({ + fields: [ + { + label: 'Birthdate', + name: 'birthdate', + required: true, + schema: expect.any(Object), + type: 'date', + maxLength: 10, + minDate: '1922-03-01', + maxDate: '2022-03-01', + }, + ], + }); + + const fieldValidator = result.fields[0].schema; + const todayDateHint = new Date().toISOString().substring(0, 10); + expect(fieldValidator.isValidSync('2020-10-10')).toBe(true); + expect(fieldValidator.isValidSync('2020-13-10')).toBe(false); + expect(() => fieldValidator.validateSync('')).toThrowError( + `Must be a valid date in yyyy-mm-dd format. e.g. ${todayDateHint}` + ); + }); + + 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('supports "group-array" field type', () => { + it('basic test', () => { + const result = createHeadlessForm( + JSONSchemaBuilder() + .addInput({ + dependent_details: mockGroupArrayInput, + }) + .build() + ); + + expect(result).toMatchObject({ + fields: [ + { + type: 'group-array', + description: 'Add the dependents you claim below', + label: 'Child details', + name: 'dependent_details', + required: false, + fields: expect.any(Function), + addFieldText: 'Add new field', + }, + ], + }); + + // Validations + const fieldValidator = result.fields[0].schema; + // nthfields are required + expect( + fieldValidator.isValidSync([ + { + birthdate: '', + full_name: '', + sex: '', + }, + ]) + ).toBe(false); + // date is invalid + expect( + fieldValidator.isValidSync([ + { + birthdate: 'invalidate date', + full_name: 'John Doe', + sex: 'male', + }, + ]) + ).toBe(false); + // all good + expect( + fieldValidator.isValidSync([ + { + birthdate: '2021-12-04', + full_name: 'John Doe', + sex: 'male', + }, + ]) + ).toBe(true); + + const nestedFieldsFromResult = result.fields[0].fields(); + expect(nestedFieldsFromResult).toMatchObject([ + { + type: 'text', + description: 'Enter your child’s full name', + maxLength: 255, + nameKey: 'full_name', + label: 'Child Full Name', + name: 'full_name', + required: true, + }, + { + type: 'date', + name: 'birthdate', + label: 'Child Birthdate', + required: true, + description: 'Enter your child’s date of birth', + maxLength: 255, + nameKey: 'birthdate', + }, + { + type: 'radio', + name: 'sex', + label: 'Child Sex', + options: [ + { + label: 'Male', + value: 'male', + }, + { + label: 'Female', + value: 'female', + }, + ], + required: true, + description: + 'We know sex is non-binary but for insurance and payroll purposes, we need to collect this information.', + nameKey: 'sex', + }, + ]); + }); + + it('nested fields (native, core and custom) has correct validations', () => { + const { handleValidation } = createHeadlessForm({ + properties: { + break_schedule: { + title: 'Work schedule', + type: 'array', + presentation: { + inputType: 'group-array', + }, + items: { + properties: { + minutes_native: { + title: 'Minutes of break (native)', + type: 'integer', + minimum: 60, + // without presentation.inputType + }, + minutes_core: { + title: 'Minutes of break (core)', + type: 'integer', + minimum: 60, + presentation: { + inputType: 'number', // a core inputType + }, + }, + minutes_custom: { + title: 'Minutes of break (custom)', + type: 'integer', + minimum: 60, + presentation: { + inputType: 'hour', // a custom inputType + }, + }, + }, + required: ['weekday', 'minutes_native', 'minutes_core', 'minutes_custom'], + }, + }, + }, + required: ['break_schedule'], + }); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + // Given empty, it says it's required + expect(validateForm({})).toEqual({ + break_schedule: 'Required field', + }); + + // Given empty fields, it mentions nested required fields + expect( + validateForm({ + break_schedule: [{}], + }) + ).toEqual({ + break_schedule: [ + { + minutes_native: 'Required field', + minutes_core: 'Required field', + minutes_custom: 'Required field', + }, + ], + }); + + // Given correct values, it's all valid. + expect( + validateForm({ + break_schedule: [ + { + minutes_native: 60, + minutes_core: 60, + minutes_custom: 60, + }, + ], + }) + ).toBeUndefined(); + + // Given invalid values, the validation is triggered. + expect( + validateForm({ + break_schedule: [ + { + minutes_native: 50, + minutes_core: 50, + minutes_custom: 50, + }, + ], + }) + ).toEqual({ + break_schedule: [ + { + minutes_core: 'Must be greater or equal to 60', + minutes_native: 'Must be greater or equal to 60', + minutes_custom: 'Must be greater or equal to 60', + }, + ], + }); + }); + + it('can pass custom field attributes', () => { + const result = createHeadlessForm( + { + properties: { + children_basic: mockGroupArrayInput, + children_custom: mockGroupArrayInput, + }, + }, + { + customProperties: { + children_custom: { + 'data-foo': 'baz', + }, + }, + } + ); + + expect(result).toMatchObject({ + fields: [ + { + label: 'Child details', + name: 'children_basic', + required: false, + type: 'group-array', + inputType: 'group-array', + jsonType: 'array', + fields: expect.any(Function), // This is what makes the field work + }, + { + label: 'Child details', + name: 'children_custom', + type: 'group-array', + inputType: 'group-array', + jsonType: 'array', + required: false, + 'data-foo': 'baz', // check that custom property is properly propagated + fields: expect.any(Function), // This is what makes the field work + }, + ], + }); + }); + }); + + it('supports "fieldset" field type', () => { + const result = createHeadlessForm( + JSONSchemaBuilder() + .addInput({ + fieldset: mockFieldset, + }) + .build() + ); + + expect(result).toMatchObject({ + fields: [ + { + description: 'Fieldset description', + label: 'Fieldset title', + name: 'fieldset', + type: 'fieldset', + required: false, + fields: [ + { + description: 'The number of your national identification (max 10 digits)', + label: 'ID number', + name: 'id_number', + type: 'text', + required: true, + }, + { + description: 'How many open tabs do you have?', + label: 'Tabs', + maximum: 10, + minimum: 1, + name: 'tabs', + type: 'number', + required: false, + }, + ], + }, + ], + }); + }); + + 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', + }, + ], + }, + ], + }); + }); + + it('supports nested "fieldset" field type', () => { + const result = createHeadlessForm( + JSONSchemaBuilder() + .addInput({ + nestedFieldset: mockNestedFieldset, + }) + .build() + ); + + expect(result).toMatchObject({ + fields: [ + { + label: 'Nested fieldset title', + description: 'Nested fieldset description', + name: 'nestedFieldset', + type: 'fieldset', + required: false, + fields: [ + { + description: 'Fieldset description', + label: 'Fieldset title', + name: 'innerFieldset', + type: 'fieldset', + required: false, + fields: [ + { + description: 'The number of your national identification (max 10 digits)', + label: 'ID number', + name: 'id_number', + type: 'text', + required: true, + }, + { + description: 'How many open tabs do you have?', + label: 'Tabs', + maximum: 10, + minimum: 1, + name: 'tabs', + type: 'number', + required: false, + }, + ], + }, + ], + }, + ], + }); + }); + + it('supported "fieldset" with scoped conditionals', () => { + const { handleValidation } = createHeadlessForm(schemaFieldsetScopedCondition, {}); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + // The "child.has_child" is required + expect(validateForm({})).toEqual({ + child: { + has_child: 'Required field', + }, + }); + + // The "child.no" is valid + expect( + validateForm({ + child: { + has_child: 'no', + }, + }) + ).toBeUndefined(); + + // Invalid because it expect child.age too + expect( + validateForm({ + child: { + has_child: 'yes', + }, + }) + ).toEqual({ + child: { + age: 'Required field', + }, + }); + + // Valid without optional child.passport_id + expect( + validateForm({ + child: { + has_child: 'yes', + age: 15, + }, + }) + ).toBeUndefined(); + + // Valid with optional child.passport_id + expect( + validateForm({ + child: { + has_child: 'yes', + age: 15, + passport_id: 'asdf', + }, + }) + ).toBeUndefined(); + }); + + it('should set any nested "fieldset" form values to null when they are invisible', async () => { + const { handleValidation } = createHeadlessForm(schemaFieldsetScopedCondition, {}); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + const formValues = { + child: { + has_child: 'yes', + age: 15, + }, + }; + + await expect(validateForm(formValues)).toBeUndefined(); + expect(formValues.child.age).toBe(15); + + formValues.child.has_child = 'no'; + // form value updates re-validate; see computeYupSchema() + await expect(validateForm(formValues)).toBeUndefined(); + + // when child.has_child is 'no' child.age is invisible + expect(formValues.child.age).toBe(null); + }); + + it('support "email" field type', () => { + const result = createHeadlessForm(schemaInputTypeEmail); + + expect(result).toMatchObject({ + fields: [ + { + description: 'Enter your email address', + label: 'Email address', + name: 'email_address', + required: true, + schema: expect.any(Object), + type: 'email', + maxLength: 255, + }, + ], + }); + + const fieldValidator = result.fields[0].schema; + expect(fieldValidator.isValidSync('test@gmail.com')).toBe(true); + expect(() => fieldValidator.validateSync('ffsdf')).toThrowError( + 'Please enter a valid email address' + ); + expect(() => fieldValidator.validateSync(undefined)).toThrowError('Required field'); + }); + + describe('supports "checkbox" field type', () => { + describe('checkbox as string', () => { + it('required: only accept the value in "checkboxValue"', () => { + const result = createHeadlessForm(schemaInputTypeCheckbox); + const checkboxField = result.fields.find((field) => field.name === 'contract_duration'); + + expect(checkboxField).toMatchObject({ + description: + 'I acknowledge that all employees in France will be hired on indefinite contracts.', + label: 'Contract duration', + name: 'contract_duration', + type: 'checkbox', + checkboxValue: 'Permanent', + }); + expect(checkboxField).not.toHaveProperty('default'); // ensure it's not checked by default. + + const fieldValidator = checkboxField.schema; + expect(fieldValidator.isValidSync('Permanent')).toBe(true); + expect(() => fieldValidator.validateSync(undefined)).toThrowError( + 'Please acknowledge this field' + ); + }); + + it('required checked: returns a default value', () => { + const result = createHeadlessForm(schemaInputTypeCheckbox); + const checkboxField = result.fields.find( + (field) => field.name === 'contract_duration_checked' + ); + + expect(checkboxField).toMatchObject({ + default: 'Permanent', + checkboxValue: 'Permanent', + }); + }); + }); + + describe('checkbox as boolean', () => { + it('optional: Accepts true or false', () => { + const result = createHeadlessForm(schemaInputTypeCheckboxBooleans); + const checkboxField = result.fields.find((field) => field.name === 'boolean_empty'); + + expect(checkboxField).toMatchObject({ + checkboxValue: true, + }); + expect(checkboxField).not.toHaveProperty('default'); // ensure it's not checked by default. + + const fieldValidator = checkboxField.schema; + expect(fieldValidator.isValidSync(true)).toBe(true); + expect(fieldValidator.isValidSync(false)).toBe(true); + expect(fieldValidator.isValidSync(undefined)).toBe(true); + expect(() => fieldValidator.validateSync('foo')).toThrowError( + 'this must be a `boolean` type, but the final value was: `"foo"`.' + ); + }); + + it('required: Only accepts true', () => { + const result = createHeadlessForm(schemaInputTypeCheckboxBooleans); + const checkboxField = result.fields.find((field) => field.name === 'boolean_required'); + + expect(checkboxField).toMatchObject({ + checkboxValue: true, + }); + + const fieldValidator = checkboxField.schema; + expect(fieldValidator.isValidSync(true)).toBe(true); + expect(() => fieldValidator.validateSync(false)).toThrowError( + 'Please acknowledge this field' + ); + }); + + it('checked: returns default: true', () => { + const result = createHeadlessForm(schemaInputTypeCheckboxBooleans); + const checkboxField = result.fields.find((field) => field.name === 'boolean_checked'); + + expect(checkboxField).toMatchObject({ + checkboxValue: true, + default: true, + }); + }); + }); + }); + + describe('supports custom inputType (eg "hour")', () => { + it('as required, optional, and mixed types', () => { + const { fields, handleValidation } = createHeadlessForm( + { + properties: { + start_time: { + title: 'Starting time', + type: 'string', + presentation: { + inputType: 'hour', + }, + }, + pause: { + title: 'Pause time (optional)', + type: 'string', + presentation: { + inputType: 'hour', + }, + }, + end_time: { + title: 'Finishing time (optional)', + type: ['null', 'string'], // ensure it supports mix types (array) (optional/null) + presentation: { + inputType: 'hour', + }, + }, + }, + required: ['start_time'], + }, + { + strictInputType: false, + } + ); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + const commonAttrs = { + type: 'hour', + inputType: 'hour', + jsonType: 'string', + schema: expect.any(Object), + }; + expect(fields).toMatchObject([ + { + name: 'start_time', + label: 'Starting time', + ...commonAttrs, + }, + { + name: 'pause', + label: 'Pause time (optional)', + ...commonAttrs, + }, + { + name: 'end_time', + label: 'Finishing time (optional)', + ...commonAttrs, + jsonType: ['null', 'string'], + }, + ]); + + expect(validateForm({})).toEqual({ + start_time: 'Required field', + }); + + expect(validateForm({ start_time: '08:30' })).toBeUndefined(); + }); + }); + }); + + describe('validation options', () => { + it('given invalid values it returns both yupError and formErrors', () => { + const { handleValidation } = createHeadlessForm(schemaInputTypeText); + + const { formErrors, yupError } = handleValidation({}); + + // Assert the yupError shape is really a YupError + expect(yupError).toEqual(expect.any(Error)); + expect(yupError.inner[0].path).toBe('id_number'); + expect(yupError.inner[0].message).toBe('Required field'); + + // Assert the converted YupError to formErrors + expect(formErrors).toEqual({ + id_number: 'Required field', + }); + }); + }); + + describe('x-jsf-presentation attribute', () => { + it('support field with "x-jsf-presentation.statement"', () => { + const result = createHeadlessForm(schemaInputWithStatement); + + expect(result).toMatchObject({ + fields: [ + { + name: 'bonus', + label: 'Bonus', + type: 'text', + statement: { + description: 'This is a custom statement message.', + inputType: 'statement', + severity: 'info', + }, + }, + { + name: 'a_or_b', + label: 'A dropdown', + type: 'select', + statement: { + description: 'This is another statement message, but more severe.', + inputType: 'statement', + severity: 'warning', + }, + }, + ], + }); + }); + }); + + describe('property misc attributes', () => { + it('pass readOnly to field', () => { + const result = createHeadlessForm({ + properties: { + secret: { + title: 'Secret code', + readOnly: true, + type: 'string', + presentation: { + inputType: 'text', + }, + }, + }, + }); + + expect(result).toMatchObject({ + fields: [ + { + name: 'secret', + label: 'Secret code', + schema: expect.any(Object), + readOnly: true, + }, + ], + }); + }); + + it('pass "deprecated" attributes to field', () => { + const result = createHeadlessForm({ + properties: { + secret: { + title: 'Age', + type: 'number', + deprecated: true, + presentation: { + inputType: 'number', + deprecated: { + description: 'Deprecated in favor of "birthdate".', + }, + }, + }, + }, + }); + + expect(result).toMatchObject({ + fields: [ + { + type: 'number', + name: 'secret', + label: 'Age', + schema: expect.any(Object), + deprecated: { + description: 'Deprecated in favor of "birthdate".', + }, + }, + ], + }); + }); + + it('pass "description" to field', () => { + const result = createHeadlessForm({ + properties: { + plain: { + title: 'Regular', + description: 'I am regular', + presentation: { inputType: 'text' }, + }, + html: { + title: 'Name', + description: 'I am regular', + presentation: { + description: 'I am bold.', + inputType: 'text', + }, + }, + }, + }); + + expect(result).toMatchObject({ + fields: [ + { description: 'I am regular' }, + { description: 'I am bold.' }, + ], + }); + }); + + it('passes scopedJsonSchema to each field', () => { + const { fields } = createHeadlessForm(schemaWithoutInputTypes, { + strictInputType: false, + }); + + const getByName = (fieldList, name) => fieldList.find((f) => f.name === name); + + const aFieldInRoot = getByName(fields, 'a_string'); + // It's the entire json schema + expect(aFieldInRoot.scopedJsonSchema).toEqual(schemaWithoutInputTypes); + + const aFieldset = getByName(fields, 'a_object'); + const aFieldInTheFieldset = getByName(aFieldset.fields, 'foo'); + + // It's only the json schema of that fieldset + expect(aFieldInTheFieldset.scopedJsonSchema).toEqual( + schemaWithoutInputTypes.properties.a_object + ); + }); + + describe('Order of fields', () => { + it('sorts fields based on presentation.position keyword (deprecated)', () => { + const { fields } = createHeadlessForm(schemaWithPositionDeprecated); + + // Assert the order from the original schema object + expect(Object.keys(schemaWithPositionDeprecated.properties)).toEqual([ + 'age', + 'street', + 'username', + ]); + expect(Object.keys(schemaWithPositionDeprecated.properties.street.properties)).toEqual([ + 'line_one', + 'postal_code', + 'number', + ]); + + // Assert the Fields order + const fieldsByName = fields.map((f) => f.name); + expect(fieldsByName).toEqual(['username', 'age', 'street']); + + const fieldsetByName = fields[2].fields.map((f) => f.name); + expect(fieldsetByName).toEqual(['line_one', 'number', 'postal_code']); + }); + + it('sorts fields based on x-jsf-order keyword', () => { + const { fields } = createHeadlessForm(schemaWithOrderKeyword); + + // Assert the order from the original schema object + expect(Object.keys(schemaWithOrderKeyword.properties)).toEqual([ + 'age', + 'street', + 'username', + ]); + expect(Object.keys(schemaWithOrderKeyword.properties.street.properties)).toEqual([ + 'line_one', + 'postal_code', + 'number', + ]); + + // Assert the Fields order + const fieldsByName = fields.map((f) => f.name); + expect(fieldsByName).toEqual(['username', 'age', 'street']); + + const fieldsetByName = fields[2].fields.map((f) => f.name); + expect(fieldsetByName).toEqual(['line_one', 'number', 'postal_code']); + }); + + it('sorts fields based on original properties (wihout x-jsf-order)', () => { + // Assert the sample schema has x-jsf-order + expect(schemaWithOrderKeyword['x-jsf-order']).toBeDefined(); + + const schemaWithoutOrder = { + ...schemaWithOrderKeyword, + 'x-jsf-order': undefined, + }; + const { fields } = createHeadlessForm(schemaWithoutOrder); + + const originalOrder = ['age', 'street', 'username']; + // Assert the order from the original schema object + expect(Object.keys(schemaWithoutOrder.properties)).toEqual(originalOrder); + + // Assert the order of fields is the same as the original object + const fieldsByName = fields.map((f) => f.name); + expect(fieldsByName).toEqual(originalOrder); + }); + }); + }); + + describe('when a field is required', () => { + let fields; + beforeEach(() => { + const result = createHeadlessForm( + buildJSONSchemaInput({ presentationFields: { inputType: 'text' }, required: true }) + ); + fields = result.fields; + }); + describe('and value is empty', () => { + it('should throw an error', async () => + expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate({ test: '' }) + ).rejects.toMatchObject({ errors: ['Required field'] })); + }); + describe('and value is defined', () => { + it('should validate field', async () => { + const assertObj = { test: 'Hello' }; + return expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate(assertObj) + ).resolves.toEqual(assertObj); + }); + }); + }); + + describe('when a field is number', () => { + let fields; + beforeEach(() => { + const result = createHeadlessForm( + buildJSONSchemaInput({ presentationFields: { inputType: 'number' } }) + ); + fields = result.fields; + }); + describe('and value is a string', () => { + it('should throw an error', async () => + expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate({ test: 'Hello' }) + ).rejects.toThrow()); + }); + describe('and value is a number', () => { + it('should validate field', async () => { + const assertObj = { test: 3 }; + return expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate(assertObj) + ).resolves.toEqual(assertObj); + }); + }); + }); + + describe('when a field has a maxLength of 10', () => { + let fields; + beforeEach(() => { + const result = createHeadlessForm( + buildJSONSchemaInput({ + presentationFields: { inputType: 'text' }, + inputFields: { maxLength: 10 }, + }) + ); + fields = result.fields; + }); + describe('and value is greater than that', () => { + it('should throw an error', async () => + expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate({ test: 'Hello Mr John Doe' }) + ).rejects.toMatchObject({ errors: ['Please insert up to 10 characters'] })); + }); + describe('and value is less than that', () => { + it('should validate field', async () => { + const assertObj = { test: 'Hello John' }; + return expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate(assertObj) + ).resolves.toEqual(assertObj); + }); + }); + }); + + describe('when a field has a minLength of 2', () => { + let fields; + beforeEach(() => { + const result = createHeadlessForm( + buildJSONSchemaInput({ + presentationFields: { inputType: 'text' }, + inputFields: { minLength: 2 }, + }) + ); + fields = result.fields; + }); + describe('and value is smaller than that', () => { + it('should throw an error', async () => + expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate({ test: 'H' }) + ).rejects.toMatchObject({ errors: ['Please insert at least 2 characters'] })); + }); + describe('and value is greater than that', () => { + it('should validate field', async () => { + const assertObj = { test: 'Hello John' }; + return expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate(assertObj) + ).resolves.toEqual(assertObj); + }); + }); + }); + + describe('when a field has a minimum of 0', () => { + let fields; + beforeEach(() => { + const result = createHeadlessForm( + buildJSONSchemaInput({ + presentationFields: { inputType: 'number' }, + inputFields: { minimum: 0 }, + }) + ); + fields = result.fields; + }); + + describe('and value is less than that', () => { + it('should throw an error', async () => + expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate({ test: -1 }) + ).rejects.toMatchObject({ errors: ['Must be greater or equal to 0'] })); + }); + + describe('and value is greater than that', () => { + it('should validate field', async () => { + const assertObj = { test: 4 }; + return expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate(assertObj) + ).resolves.toEqual(assertObj); + }); + }); + }); + + describe('when a field has a maximum of 10', () => { + let fields; + beforeEach(() => { + const result = createHeadlessForm( + buildJSONSchemaInput({ + presentationFields: { inputType: 'number' }, + inputFields: { maximum: 10 }, + }) + ); + fields = result.fields; + }); + + describe('and value is greater than that', () => { + it('should throw an error', async () => + expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate({ test: 11 }) + ).rejects.toMatchObject({ errors: ['Must be smaller or equal to 10'] })); + }); + + describe('and value is greater than that', () => { + it('should validate field', async () => { + const assertObj = { test: 4 }; + return expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate(assertObj) + ).resolves.toEqual(assertObj); + }); + }); + }); + + describe('when a field has a pattern', () => { + let fields; + beforeEach(() => { + const result = createHeadlessForm( + buildJSONSchemaInput({ + presentationFields: { inputType: 'text' }, + inputFields: { pattern: '^[0-9]{3}-[0-9]{2}-(?!0{4})[0-9]{4}$' }, + }) + ); + fields = result.fields; + }); + describe('and value does not match the pattern', () => { + it('should throw an error', async () => + expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate({ test: 'Hello' }) + ).rejects.toMatchObject({ errors: [expect.any(String)] })); + }); + describe('and value matches the pattern', () => { + it('should validate field', async () => { + const assertObj = { test: '401-85-1950' }; + return expect( + object() + .shape({ + test: fields[0].schema, + }) + .validate(assertObj) + ).resolves.toEqual(assertObj); + }); + }); + }); + + 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); + }); + }); + + 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 inccorrect 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('when a field has conditional presentation properties', () => { + it('adds .jsf-statement to nested statement markup when visible', () => { + const { fields } = createHeadlessForm(schemaWithConditionalPresentationProperties, { + initialValues: { + // show the hidden statement + mock_radio: 'no', + }, + }); + + expect(fields[0].statement.description).toBe( + `conditional statement markup` + ); + }); + }); + + describe('when a JSON Schema is provided', () => { + const getByName = (fields, name) => fields.find((f) => f.name === name); + + describe('and all fields are optional', () => { + let handleValidation; + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + beforeEach(() => { + const result = JSONSchemaBuilder() + .addInput({ textInput: mockTextInput }) + .addInput({ numberInput: mockNumberInput }) + .build(); + const { handleValidation: handleValidationEach } = createHeadlessForm(result); + handleValidation = handleValidationEach; + }); + + it.each([ + [ + 'validation should return true when the object has empty values', + { textInput: '' }, + undefined, + ], + [ + 'validation should return true when object is valid', + { textInput: 'abcde', numberInput: 9 }, + undefined, + ], + ])('%s', (_, value, errors) => { + const testValue = validateForm(value); + if (errors) { + expect(testValue).toEqual(errors); + } else { + expect(testValue).toBeUndefined(); + } + }); + }); + + describe('and all fields are mandatory', () => { + let handleValidation; + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + beforeEach(() => { + const result = JSONSchemaBuilder() + .addInput({ textInput: mockTextInput }) + .addInput({ numberInput: mockNumberInput }) + .setRequiredFields(['numberInput', 'textInput']) + .build(); + const { handleValidation: handleValidationEach } = createHeadlessForm(result); + handleValidation = handleValidationEach; + }); + + it.each([ + [ + 'validation should return false when value is an empty object', + {}, + { + numberInput: 'Required field', + textInput: 'Required field', + }, + ], + [ + 'validation should return false when value is an object with null values', + { textInput: null, numberInput: null }, + { numberInput: 'Required field', textInput: 'Required field' }, + ], + [ + 'validation should return false when value is an object with empty values', + { textInput: '', numberInput: '' }, + { numberInput: 'The value must be a number', textInput: 'Required field' }, + ], + [ + 'validation should return false when one value is empty', + { textInput: '986-39-076', numberInput: '' }, + { numberInput: 'The value must be a number' }, + ], + [ + 'validation should return false a numeric field is not a number', + { textInput: '986-39-076', numberInput: 'not a number' }, + { numberInput: 'The value must be a number' }, + ], + [ + 'validation should return true when object is valid', + { textInput: 'abc-xy-asd', numberInput: 9 }, + undefined, + ], + ])('%s', (_, values, errors) => { + const testValue = validateForm(values); + if (errors) { + expect(testValue).toEqual(errors); + } else { + expect(testValue).toBeUndefined(); + } + }); + + describe('and one field has pattern validation', () => { + beforeEach(() => { + const result = JSONSchemaBuilder() + .addInput({ patternTextInput: mockTextPatternInput }) + .setRequiredFields(['patternTextInput']) + .build(); + const { handleValidation: handleValidationEach } = createHeadlessForm(result); + handleValidation = handleValidationEach; + }); + + it.each([ + [ + 'validation should return false when a value does not match a pattern', + { patternTextInput: 'abc-xy-asd' }, + { patternTextInput: expect.stringMatching(/Must have a valid format. E.g./i) }, + ], + [ + 'validation should return true when value matches the pattern', + { patternTextInput: '986-39-0716' }, + undefined, + ], + ])('%s', (_, values, errors) => { + const testValue = validateForm(values); + if (errors) { + expect(testValue).toEqual(errors); + } else { + expect(testValue).toBeUndefined(); + } + }); + }); + + describe('and one field has max length validation', () => { + beforeEach(() => { + const result = JSONSchemaBuilder() + .addInput({ maxLengthTextInput: mockTextMaxLengthInput }) + .setRequiredFields(['maxLengthTextInput']) + .build(); + const { handleValidation: handleValidationEach } = createHeadlessForm(result); + handleValidation = handleValidationEach; + }); + + it.each([ + [ + 'validation should return false when a value is greater than the limit', + { maxLengthTextInput: 'Hello John Dow' }, + { maxLengthTextInput: 'Please insert up to 10 characters' }, + ], + [ + 'validation should return true when value is within the limit', + { maxLengthTextInput: 'Hello John' }, + undefined, + ], + ])('%s', (_, values, errors) => { + const testValue = validateForm(values); + if (errors) { + expect(testValue).toEqual(errors); + } else { + expect(testValue).toBeUndefined(); + } + }); + }); + }); + + describe('and fields are dynamically required/optional', () => { + it('applies correct validation for single-value based conditionals', async () => { + const { fields, handleValidation } = createHeadlessForm(schemaDynamicValidationConst); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + expect( + validateForm({ + validate_tabs: 'no', + a_fieldset: { + id_number: 123, + }, + mandatory_group_array: 'no', + }) + ).toBeUndefined(); + + const getTabsField = () => + fields.find((f) => f.name === 'a_fieldset').fields.find((f) => f.name === 'tabs'); + + expect(getTabsField().required).toBeFalsy(); + + expect( + validateForm({ + validate_tabs: 'yes', + a_fieldset: { + id_number: 123, + }, + mandatory_group_array: 'no', + }) + ).toEqual({ + a_fieldset: { + tabs: 'Required field', + }, + }); + + expect(getTabsField().required).toBeTruthy(); + + expect( + validateForm({ + validate_tabs: 'yes', + a_fieldset: { + id_number: 123, + }, + mandatory_group_array: 'yes', + a_group_array: [{ full_name: 'adfs' }], + }) + ).toEqual({ a_fieldset: { tabs: 'Required field' } }); + + expect( + validateForm({ + validate_tabs: 'yes', + a_fieldset: { + id_number: 123, + tabs: 2, + }, + mandatory_group_array: 'no', + }) + ).toBeUndefined(); + }); + + it('applies correct validation for minimum/maximum conditionals', async () => { + const { handleValidation } = createHeadlessForm(schemaDynamicValidationMinimumMaximum); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + // Check for minimum condition + expect( + validateForm({ + a_number: 0, + }) + ).toEqual({ + a_conditional_text: 'Required field', + a_number: 'Must be greater or equal to 1', + }); + + // Check for maximum condition + expect( + validateForm({ + a_number: 11, + }) + ).toEqual({ + a_conditional_text: 'Required field', + a_number: 'Must be smaller or equal to 10', + }); + + // Check for absence of a_number + expect(validateForm({})).toEqual({ + a_conditional_text: 'Required field', + }); + + // Check for number within range + expect( + validateForm({ + a_number: 5, + }) + ).toBeUndefined(); + }); + + it('applies correct validation for minLength/maxLength conditionals', async () => { + const { handleValidation } = createHeadlessForm(schemaDynamicValidationMinLengthMaxLength); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + const formError = { + a_conditional_text: 'Required field', + }; + // By default a_conditional_text is required. + expect(validateForm({})).toEqual(formError); + + // Check for minimum length condition - a_text >= 3 chars + expect( + validateForm({ + a_text: 'Foo', + }) + ).toBeUndefined(); + + // Check for maximum length condition - a_text <= 5 chars + expect( + validateForm({ + a_text: 'Fooba', + }) + ).toBeUndefined(); + + // Check for text out of length range (7 chars) + expect( + validateForm({ + a_text: 'Foobaaz', + }) + ).toEqual(formError); + + // Check for text out of length range (2 chars) + expect( + validateForm({ + a_text: 'Fe', + }) + ).toEqual(formError); + }); + + it('applies correct validation for array-contain based conditionals', async () => { + const { handleValidation } = createHeadlessForm(schemaDynamicValidationContains); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + expect( + validateForm({ + validate_fieldset: ['id_number'], + a_fieldset: { + id_number: 123, + }, + }) + ).toBeUndefined(); + + expect( + validateForm({ + validate_fieldset: ['id_number', 'all'], + a_fieldset: { + id_number: 123, + }, + }) + ).toEqual({ + a_fieldset: { + tabs: 'Required field', + }, + }); + + expect( + validateForm({ + validate_fieldset: ['id_number', 'all'], + a_fieldset: { + id_number: 123, + tabs: 2, + }, + }) + ).toBeUndefined(); + }); + + it('applies correct validation for fieldset fields', async () => { + const { handleValidation } = createHeadlessForm(schemaDynamicValidationContains); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + expect( + validateForm({ + validate_fieldset: ['id_number'], + a_fieldset: { + id_number: 123, + }, + }) + ).toBeUndefined(); + + expect( + validateForm({ + validate_fieldset: ['id_number', 'all'], + a_fieldset: { + id_number: 123, + }, + }) + ).toEqual({ + a_fieldset: { + tabs: 'Required field', + }, + }); + + expect( + validateForm({ + validate_fieldset: ['id_number', 'all'], + a_fieldset: { + id_number: 123, + tabs: 2, + }, + }) + ).toBeUndefined(); + }); + + it('applies any of the validation alternatives in a anyOf branch', async () => { + const { handleValidation } = createHeadlessForm(schemaAnyOfValidation); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + expect( + validateForm({ + field_a: '123', + }) + ).toBeUndefined(); + + expect( + validateForm({ + field_b: '456', + }) + ).toEqual({ field_c: 'Required field' }); + + expect( + validateForm({ + field_b: '456', + field_c: '789', + }) + ).toBeUndefined(); + + expect( + validateForm({ + field_a: '123', + field_c: '789', + }) + ).toBeUndefined(); + + expect( + validateForm({ + field_a: '123', + field_b: '456', + field_c: '789', + }) + ).toBeUndefined(); + }); + + describe('nested conditionals', () => { + it('given empty values, runs "else" (gets hidden)', () => { + const { fields } = createHeadlessForm(schemaWithConditionalReadOnlyProperty, { + field_a: null, + }); + expect(getByName(fields, 'field_b').isVisible).toBe(false); + }); + + it('given a match, runs "then" (turns visible and editable)', () => { + const { fields } = createHeadlessForm(schemaWithConditionalReadOnlyProperty, { + initialValues: { field_a: 'yes' }, + }); + expect(getByName(fields, 'field_b').isVisible).toBe(true); + expect(getByName(fields, 'field_b').readOnly).toBe(false); + }); + + it('given a nested match, runs "else-then" (turns visible but readOnly)', () => { + const { fields } = createHeadlessForm(schemaWithConditionalReadOnlyProperty, { + initialValues: { field_a: 'no' }, + }); + expect(getByName(fields, 'field_b').isVisible).toBe(true); + expect(getByName(fields, 'field_b').readOnly).toBe(true); + }); + }); + + describe('conditional fields (incorrectly done)', () => { + // this catches the typical scenario where developers forget to set the if.required[] + + it('given empty values, the incorrect conditional runs "then" instead of "else"', () => { + const { fields: fieldsEmpty } = createHeadlessForm(schemaWithWrongConditional, { + initialValues: { field_a: null, field_a_wrong: null }, + }); + // The dependent correct field gets hidden, but... + expect(getByName(fieldsEmpty, 'field_b').isVisible).toBe(false); + // ...the dependent wrong field stays visible because the + // conditional is wrong (it's missing the if.required[]) + expect(getByName(fieldsEmpty, 'field_b_wrong').isVisible).toBe(true); + }); + + it('given a match ("yes"), both runs "then" (turn visible)', () => { + const { fields: fieldsVisible } = createHeadlessForm(schemaWithWrongConditional, { + initialValues: { field_a: 'yes', field_a_wrong: 'yes' }, + }); + expect(getByName(fieldsVisible, 'field_b').isVisible).toBe(true); + expect(getByName(fieldsVisible, 'field_b_wrong').isVisible).toBe(true); + }); + + it('not given a match ("no"), both run else (stay hidden)', () => { + const { fields: fieldsHidden } = createHeadlessForm(schemaWithWrongConditional, { + initialValues: { field_a: 'no', field_a_wrong: 'no' }, + }); + expect(getByName(fieldsHidden, 'field_b').isVisible).toBe(false); + expect(getByName(fieldsHidden, 'field_b_wrong').isVisible).toBe(false); + }); + }); + + it('checkbox should have no initial value when its dynamically shown and invisible', () => { + const { fields } = createHeadlessForm(schemaWithConditionalAcknowledgementProperty, { + initialValues: { + field_a: 'no', + }, + }); + const dependentField = getByName(fields, 'field_b'); + expect(dependentField.isVisible).toBe(false); + expect(dependentField.value).toBe(undefined); + }); + + it('checkbox should have no initial value when its dynamically shown and visible', () => { + const { fields } = createHeadlessForm(schemaWithConditionalAcknowledgementProperty, { + initialValues: { + field_a: 'yes', + }, + }); + const dependentField = getByName(fields, 'field_b'); + expect(dependentField.isVisible).toBe(true); + expect(dependentField.value).toBe(undefined); + }); + }); + }); + + // TODO: delete after migration to x-jsf-errorMessage is completed + describe('Throwing custom error messages using errorMessage (deprecated)', () => { + it.each([ + [ + 'type', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + errorMessage: { type: 'It has to be a number.' }, + }, + }) + .build(), + { numberInput: 'Two' }, + { + numberInput: 'It has to be a number.', + }, + false, + ], + [ + 'minimum', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + errorMessage: { minimum: 'I am a custom error message' }, + }, + }) + .build(), + { numberInput: -1 }, + { + numberInput: 'I am a custom error message', + }, + false, + ], + [ + 'required', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + errorMessage: { required: 'I am a custom error message' }, + }, + }) + .setRequiredFields(['numberInput']) + .build(), + {}, + { + numberInput: 'I am a custom error message', + }, + ], + [ + 'required (ignored because it is optional)', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + errorMessage: { required: 'I am a custom error message' }, + }, + }) + .build(), + {}, + undefined, + ], + [ + 'maximum', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + errorMessage: { maximum: 'I am a custom error message' }, + }, + }) + .build(), + { numberInput: 11 }, + { + numberInput: 'I am a custom error message', + }, + ], + [ + 'minLength', + JSONSchemaBuilder() + .addInput({ + stringInput: { + ...mockTextInput, + minLength: 3, + errorMessage: { minLength: 'I am a custom error message' }, + }, + }) + .build(), + { stringInput: 'aa' }, + { + stringInput: 'I am a custom error message', + }, + ], + [ + 'maxLength', + JSONSchemaBuilder() + .addInput({ + stringInput: { + ...mockTextInput, + maxLength: 3, + errorMessage: { maxLength: 'I am a custom error message' }, + }, + }) + .build(), + { stringInput: 'aaaa' }, + { + stringInput: 'I am a custom error message', + }, + ], + [ + 'pattern', + JSONSchemaBuilder() + .addInput({ + stringInput: { + ...mockTextInput, + pattern: '^(\\+|00)\\d*$', + errorMessage: { pattern: 'I am a custom error message' }, + }, + }) + .build(), + { stringInput: 'aaaa' }, + { + stringInput: 'I am a custom error message', + }, + ], + [ + 'maxFileSize', + JSONSchemaBuilder() + .addInput({ + fileInput: { + ...mockFileInput, + 'x-jsf-presentation': { + ...mockFileInput['x-jsf-presentation'], + maxFileSize: 1000, + }, + errorMessage: { maxFileSize: 'I am a custom error message' }, + }, + }) + .build(), + { + fileInput: [ + (() => { + const file = new File([''], 'file.png'); + Object.defineProperty(file, 'size', { value: 1024 * 1024 * 1024 }); + return file; + })(), + ], + }, + { + fileInput: 'I am a custom error message', + }, + ], + [ + 'accept', + JSONSchemaBuilder() + .addInput({ + fileInput: { + ...mockFileInput, + accept: '.pdf', + errorMessage: { accept: 'I am a custom error message' }, + }, + }) + .build(), + { + fileInput: [new File([''], 'file.docx')], + }, + { + fileInput: 'I am a custom error message', + }, + ], + ])('error message for property "%s"', (_, schema, input, errors) => { + const { handleValidation } = createHeadlessForm(schema); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + if (errors) { + expect(validateForm(input)).toEqual(errors); + } else { + expect(validateForm(input)).toBeUndefined(); + } + }); + }); + + describe('Custom error messages', () => { + it.each([ + [ + 'type', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + 'x-jsf-errorMessage': { type: 'It has to be a number.' }, + }, + }) + .build(), + { numberInput: 'Two' }, + { + numberInput: 'It has to be a number.', + }, + false, + ], + [ + 'minimum', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + 'x-jsf-errorMessage': { minimum: 'I am a custom error message' }, + }, + }) + .build(), + { numberInput: -1 }, + { + numberInput: 'I am a custom error message', + }, + false, + ], + [ + 'required', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + 'x-jsf-errorMessage': { required: 'I am a custom error message' }, + }, + }) + .setRequiredFields(['numberInput']) + .build(), + {}, + { + numberInput: 'I am a custom error message', + }, + ], + [ + 'required (ignored because it is optional)', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + 'x-jsf-errorMessage': { required: 'I am a custom error message' }, + }, + }) + .build(), + {}, + undefined, + ], + [ + 'maximum', + JSONSchemaBuilder() + .addInput({ + numberInput: { + ...mockNumberInput, + 'x-jsf-errorMessage': { maximum: 'I am a custom error message' }, + }, + }) + .build(), + { numberInput: 11 }, + { + numberInput: 'I am a custom error message', + }, + ], + [ + 'minLength', + JSONSchemaBuilder() + .addInput({ + stringInput: { + ...mockTextInput, + minLength: 3, + 'x-jsf-errorMessage': { minLength: 'I am a custom error message' }, + }, + }) + .build(), + { stringInput: 'aa' }, + { + stringInput: 'I am a custom error message', + }, + ], + [ + 'maxLength', + JSONSchemaBuilder() + .addInput({ + stringInput: { + ...mockTextInput, + maxLength: 3, + 'x-jsf-errorMessage': { maxLength: 'I am a custom error message' }, + }, + }) + .build(), + { stringInput: 'aaaa' }, + { + stringInput: 'I am a custom error message', + }, + ], + [ + 'pattern', + JSONSchemaBuilder() + .addInput({ + stringInput: { + ...mockTextInput, + pattern: '^(\\+|00)\\d*$', + 'x-jsf-errorMessage': { pattern: 'I am a custom error message' }, + }, + }) + .build(), + { stringInput: 'aaaa' }, + { + stringInput: 'I am a custom error message', + }, + ], + [ + 'maxFileSize', + JSONSchemaBuilder() + .addInput({ + fileInput: { + ...mockFileInput, + 'x-jsf-presentation': { + ...mockFileInput['x-jsf-presentation'], + maxFileSize: 1000, + }, + 'x-jsf-errorMessage': { maxFileSize: 'I am a custom error message' }, + }, + }) + .build(), + { + fileInput: [ + (() => { + const file = new File([''], 'file.png'); + Object.defineProperty(file, 'size', { value: 1024 * 1024 * 1024 }); + return file; + })(), + ], + }, + { + fileInput: 'I am a custom error message', + }, + ], + [ + 'accept', + JSONSchemaBuilder() + .addInput({ + fileInput: { + ...mockFileInput, + accept: '.pdf', + 'x-jsf-errorMessage': { accept: 'I am a custom error message' }, + }, + }) + .build(), + { + fileInput: [new File([''], 'file.docx')], + }, + { + fileInput: 'I am a custom error message', + }, + ], + ])('error message for property "%s"', (_, schema, input, errors) => { + const { handleValidation } = createHeadlessForm(schema); + const validateForm = (vals) => friendlyError(handleValidation(vals)); + + if (errors) { + expect(validateForm(input)).toEqual(errors); + } else { + expect(validateForm(input)).toBeUndefined(); + } + }); + + it('accepts with options.inputType[].errorMessage', () => { + // Sanity-check the default error message + const resultDefault = createHeadlessForm(schemaForErrorMessageSpecificity); + expect(resultDefault.handleValidation({}).formErrors).toEqual({ + weekday: 'Required field', + day: 'Required field', + month: 'Required field', + year: 'The year is mandatory.', // from x-jsf-errorMessage + }); + + // Assert the custom error message + const resultCustom = createHeadlessForm(schemaForErrorMessageSpecificity, { + ...jsfConfigForErrorMessageSpecificity, + }); + expect(resultCustom.handleValidation({}).formErrors).toEqual({ + weekday: 'Required field', // sanity-check that a different inputType keeps the default error msg. + day: 'This cannot be empty.', + month: 'This cannot be empty.', + year: 'The year is mandatory.', // error specificity: schema's msg is higher than options' msg. + }); + }); + }); + + describe('when default values are provided', () => { + describe('and "fieldset" has scoped conditionals', () => { + it('should show conditionals fields when values fullfil conditions', () => { + const result = createHeadlessForm(schemaFieldsetScopedCondition, { + initialValues: { child: { has_child: 'yes' } }, + }); + + const fieldset = result.fields[0]; + + expect(fieldset).toMatchObject({ + fields: [ + { + name: 'has_child', + required: true, + }, + { + name: 'age', + required: true, + isVisible: true, + }, + { + name: 'passport_id', + required: false, + isVisible: true, + }, + ], + }); + }); + + it('should hide conditionals fields when values do not fullfil conditions', () => { + const result = createHeadlessForm(schemaFieldsetScopedCondition, { + child: { has_child: 'no' }, + }); + + const fieldset = result.fields[0]; + + expect(fieldset).toMatchObject({ + fields: [ + { + name: 'has_child', + required: true, + }, + { + name: 'age', + required: false, + isVisible: false, + }, + { + name: 'passport_id', + required: false, + isVisible: false, + }, + ], + }); + }); + }); + }); + + describe('parser options', () => { + it('should support any custom field attribute', () => { + const customAttrs = { + something: 'foo', // a misc attribute + inputType: 'super', // overrides "textarea" + falsy: false, // accepts falsy attributes + }; + const result = createHeadlessForm( + { + properties: { + feedback: { + title: 'Your feedback', + type: 'string', + presentation: { + inputType: 'textarea', + }, + }, + }, + }, + { + customProperties: { + feedback: { + ...customAttrs, + }, + }, + } + ); + + expect(result).toMatchObject({ + fields: [ + { + name: 'feedback', + label: 'Your feedback', + jsonType: 'string', + ...customAttrs, + }, + ], + }); + }); + + it('should support custom description (checkbox)', () => { + const result = createHeadlessForm( + { + properties: { + terms: { + const: 'Agreed', + title: 'Terms', + description: 'Accept terms.', + type: 'string', + presentation: { inputType: 'checkbox' }, + }, + }, + }, + { + customProperties: { + terms: { + description: (text) => `Extra text before. ${text}`, + }, + }, + } + ); + + expect(result).toMatchObject({ + fields: [ + { + label: 'Terms', + description: 'Extra text before. Accept terms.', // ensure custom description works + name: 'terms', + required: false, + inputType: 'checkbox', + type: 'checkbox', + jsonType: 'string', + checkboxValue: 'Agreed', // ensure _composeFieldCheckbox(). transformations are passed. + }, + ], + }); + + // ensure _composeFieldCheckbox() "value" destructure happens. + expect(result.fields[0]).not.toHaveProperty('value'); + }); + + it('should ignore fields that are not present in the schema', () => { + const schemaBase = { + properties: { + feedback: { + title: 'Your feedback', + type: 'string', + presentation: { + inputType: 'textarea', + }, + }, + }, + }; + + const resultWithoutCustomProperties = createHeadlessForm(schemaBase); + const resultWithInvalidCustomProperty = createHeadlessForm(schemaBase, { + customProperties: { + unknown: { + 'data-foo': 'baz', + }, + }, + }); + + function assertResultHasNoCustomizations(result) { + expect(result.fields).toHaveLength(1); // The "unknown" is not present + expect(result.fields[0].name).toBe('feedback'); + expect(result.fields[0]).not.toHaveProperty('data-foo'); + } + + assertResultHasNoCustomizations(resultWithoutCustomProperties); + assertResultHasNoCustomizations(resultWithInvalidCustomProperty); + }); + + it('should handle custom properties when inside fieldsets', () => { + const result = createHeadlessForm( + JSONSchemaBuilder() + .addInput({ + id_number: mockNumberInput, + }) + .addInput({ + fieldset: mockFieldset, + }) + .addInput({ nestedFieldset: mockNestedFieldset }) + .build(), + { + customProperties: { + id_number: { 'data-field': 'field' }, + fieldset: { + id_number: { 'data-fieldset': 'fieldset' }, + }, + nestedFieldset: { + innerFieldset: { + id_number: { 'data-nested-fieldset': 'nested-fieldset' }, + }, + }, + }, + } + ); + + expect(result).toMatchObject({ + fields: [ + { + 'data-field': 'field', + name: 'id_number', + }, + { + name: 'fieldset', + fields: [ + { + name: 'id_number', + 'data-fieldset': 'fieldset', + }, + { + name: 'tabs', + }, + ], + }, + { + name: 'nestedFieldset', + fields: [ + { + name: 'innerFieldset', + fields: [ + { + name: 'id_number', + 'data-nested-fieldset': 'nested-fieldset', + }, + { + name: 'tabs', + }, + ], + }, + ], + }, + ], + }); + + const [fieldResult, fildsetResult, nestedFieldsetResult] = result.fields; + + // Sanity check that custom attrs are not "leaked" into other fields + // $.id_number + expect(fieldResult).toHaveProperty('name', 'id_number'); + expect(fieldResult).toHaveProperty('data-field', 'field'); + expect(fieldResult).not.toHaveProperty('data-fieldset'); + expect(fieldResult).not.toHaveProperty('data-nested-fieldset'); + + // $.fieldset.id_number + expect(fildsetResult.fields[0]).toHaveProperty('name', 'id_number'); + expect(fildsetResult.fields[0]).toHaveProperty('data-fieldset', 'fieldset'); + expect(fildsetResult.fields[0]).not.toHaveProperty('data-field'); + expect(fildsetResult.fields[0]).not.toHaveProperty('data-nested-fieldset'); + expect(fildsetResult.fields[1]).not.toHaveProperty('data-field'); + expect(fildsetResult.fields[1]).not.toHaveProperty('data-nested-fieldset'); + + // $.nestedFieldset.innerFieldset.id_number + expect(nestedFieldsetResult.fields[0].fields[0]).toHaveProperty('name', 'id_number'); + expect(nestedFieldsetResult.fields[0].fields[0]).toHaveProperty( + 'data-nested-fieldset', + 'nested-fieldset' + ); + expect(nestedFieldsetResult.fields[0].fields[0]).not.toHaveProperty('data-field'); + expect(nestedFieldsetResult.fields[0].fields[0]).not.toHaveProperty('data-fieldset'); + expect(nestedFieldsetResult.fields[0].fields[1]).not.toHaveProperty('data-field'); + expect(nestedFieldsetResult.fields[0].fields[1]).not.toHaveProperty('data-fieldset'); + }); + }); + + describe('presentation (deprecated in favor of x-jsf-presentation)', () => { + it('works well with position, description, inputType, and any other arbitrary attribute', () => { + const { fields } = createHeadlessForm({ + properties: { + day: { + title: 'Date', + presentation: { + inputType: 'date', + position: 1, + foo: 'bar', + statement: { + description: 'ss', + }, + }, + }, + time: { + title: 'Time', + presentation: { + inputType: 'clock', + description: 'Write in hh:ss format', + position: 0, + deprecated: { + description: 'In favor of X', + }, + }, + }, + }, + }); + + // Assert order from presentation.position + expect(fields[0].name).toBe('time'); + expect(fields[1].name).toBe('day'); + + // Assert spreaded attributes + expect(fields).toMatchObject([ + { + name: 'time', + description: 'Write in hh:ss format', // from presentation + inputType: 'clock', // arbitrary type from presentation + deprecated: { + description: 'In favor of X', // from presentation + }, + }, + { + name: 'day', + inputType: 'date', // arbitrary type from presentation + foo: 'bar', // spread from presentation + statement: { + // from presentation + description: 'ss', + }, + }, + ]); + }); + }); +}); diff --git a/src/tests/helpers.custom.js b/src/tests/helpers.custom.js new file mode 100644 index 00000000..f26a063f --- /dev/null +++ b/src/tests/helpers.custom.js @@ -0,0 +1,223 @@ +import { + mockTextInput, + mockNumberInput, + mockEmailInput, + mockCheckboxInput, + mockFileInput, + mockSelectInputSolo, +} from './helpers'; + +export const schemaInputTypeTextarea = { + properties: { + comment: { + title: 'Your comment', + presentation: { + inputType: 'textarea', + }, + maxLength: 250, + type: 'string', + }, + }, + required: ['comment'], +}; + +export const inputTypeCountriesSolo = { + title: 'Countries', + oneOf: [ + { title: 'Afghanistan', const: 'Afghanistan' }, + { title: 'Albania', const: 'Albania' }, + { title: 'Algeria', const: 'Algeria' }, + ], + type: 'string', + presentation: { + inputType: 'countries', + }, +}; + +export const schemaInputTypeCountriesSolo = { + properties: { + birthplace: { + ...inputTypeCountriesSolo, + title: 'Birthplace', + description: 'Where were you born?', + }, + }, + required: ['birthplace'], +}; + +export const schemaInputTypeCountriesMultiple = { + properties: { + nationality: { + title: 'Nationality', + description: 'Where are you a legal citizen?', + items: { + anyOf: [ + { title: 'Afghanistan', const: 'Afghanistan' }, + { title: 'Albania', const: 'Albania' }, + { title: 'Algeria', const: 'Algeria' }, + ], + }, + type: 'array', + presentation: { + inputType: 'countries', + }, + }, + }, + required: ['nationality'], +}; + +export const schemaInputTypeFileUploadLater = { + properties: { + b_file: { + ...mockFileInput, + title: 'File skippable', + 'x-jsf-presentation': { + ...mockFileInput['x-jsf-presentation'], + skippableLabel: "I don't have this document yet.", + description: + 'File input, with attribute "allowLaterUpload". This tells the API to mark the file as skipped so that it is asked again later in the process.', + allowLaterUpload: true, + }, + }, + }, +}; + +export const schemaInputTypeTel = { + properties: { + phone_number: { + title: 'Phone number', + description: 'Enter your telephone number', + type: 'string', + pattern: '^(\\+|00)[0-9]{6,}$', + maxLength: 30, + presentation: { + inputType: 'tel', + }, + errorMessage: { + maxLength: 'Must be at most 30 digits', + pattern: 'Please insert only the country code and phone number, without letters or spaces', + }, + }, + }, + required: ['phone_number'], +}; + +const mockTelInput = { + title: 'Phone number', + description: 'Enter your telephone number', + maxLength: 30, + presentation: { + inputType: 'tel', + }, + pattern: '^(\\+|00)\\d*$', + type: 'string', +}; + +export const mockMoneyInput = { + title: 'Weekly salary', + description: 'This field has a min and max values. Max has a custom error message.', + presentation: { + inputType: 'money', + currency: 'EUR', + }, + $comment: 'The value is in cents format. e.g. 1000 -> 10.00€', + minimum: 100000, + maximum: 500000, + 'x-jsf-errorMessage': { + type: 'Please, use US standard currency format. Ex: 1024.12', + maximum: 'No more than €5000.00', + }, + type: 'integer', +}; + +export const schemaInputTypeMoney = { + properties: { + salary: mockMoneyInput, + }, + required: ['salary'], +}; + +export const schemaCustomComponentWithAck = { + properties: { + salary: mockMoneyInput, + terms: mockCheckboxInput, + }, + required: ['salary'], +}; + +export const schemaCustomComponent = { + properties: { + salary: { + title: 'Monthly gross salary', + description: 'This field gets represented by a custom UI Component.', + presentation: { + inputType: 'money', + currency: 'EUR', + }, + type: 'integer', + 'x-jsf-errorMessage': { + type: 'Please, use US standard currency format. Ex: 1024.12', + }, + }, + }, + required: ['salary'], +}; + +export const schemaInputTypeHidden = { + properties: { + a_hidden_field_text: { + ...mockTextInput, + title: 'Text hidden', + 'x-jsf-presentation': { inputType: 'hidden' }, + default: '12345', + }, + a_hidden_field_number: { + ...mockNumberInput, + title: 'Number hidden', + 'x-jsf-presentation': { inputType: 'hidden' }, + default: 5, + }, + a_hidden_field_tel: { + ...mockTelInput, + title: 'Tel hidden', + 'x-jsf-presentation': { inputType: 'hidden' }, + default: '+123456789', + }, + a_hidden_field_email: { + ...mockEmailInput, + title: 'Email hidden', + 'x-jsf-presentation': { inputType: 'hidden' }, + default: 'test@remote.com', + }, + a_hidden_field_money: { + ...mockMoneyInput, + title: 'Money hidden', + 'x-jsf-presentation': { inputType: 'hidden', currency: 'EUR' }, + minimum: 0, + default: 12.3, + }, + a_hidden_select: { + ...mockSelectInputSolo, + title: 'Select hidden', + 'x-jsf-presentation': { inputType: 'hidden' }, + default: 'Travel Bonus', + }, + a_hidden_select_multiple: { + ...schemaInputTypeCountriesMultiple.properties.nationality, + title: 'Select multi hidden', + default: ['Albania, Algeria'], + 'x-jsf-presentation': { inputType: 'hidden' }, + const: ['Albania, Algeria'], + type: 'array', + }, + }, + required: [ + 'a_hidden_field_text', + 'a_hidden_field_number', + 'a_hidden_field_tel', + 'a_hidden_field_email', + 'a_hidden_field_money', + 'a_hidden_select', + 'a_hidden_select_multiple,', + ], +}; diff --git a/src/tests/helpers.js b/src/tests/helpers.js new file mode 100644 index 00000000..32d02f61 --- /dev/null +++ b/src/tests/helpers.js @@ -0,0 +1,1983 @@ +// ------------------------------------- +// ----------- Inputs Schema ----------- +// ------------------------------------- + +export const mockTextInput = { + title: 'ID number', + description: 'The number of your national identification (max 10 digits)', + maxLength: 10, + 'x-jsf-presentation': { + inputType: 'text', + maskSecret: 2, + }, + type: 'string', +}; + +export const mockTextInputDeprecated = { + title: 'ID number', + description: 'The number of your national identification (max 10 digits)', + maxLength: 10, + presentation: { + inputType: 'text', + maskSecret: 2, + }, + type: 'string', +}; + +export const mockTextareaInput = { + title: 'Comment', + description: 'Explain how was the organization of the event.', + 'x-jsf-presentation': { + inputType: 'textarea', + placeholder: 'Leave your comment...', + }, + maximum: 250, + type: 'string', +}; + +export const mockNumberInput = { + title: 'Tabs', + description: 'How many open tabs do you have?', + 'x-jsf-presentation': { + inputType: 'number', + }, + minimum: 1, + maximum: 10, + type: 'number', +}; + +export const mockNumberInputWithPercentage = { + title: 'Shares', + description: 'What % of shares do you own?', + 'x-jsf-presentation': { + inputType: 'number', + percentage: true, + }, + minimum: 1, + maximum: 100, + type: 'number', +}; + +export const mockNumberInputWithPercentageAndCustomRange = { + ...mockNumberInputWithPercentage, + minimum: 50, + maximum: 70, +}; + +export const mockTextPatternInput = { + ...mockTextInput, + maxLength: 11, + pattern: '^[0-9]{3}-[0-9]{2}-(?!0{4})[0-9]{4}$', +}; + +export const mockTextMaxLengthInput = { + ...mockTextInput, + maxLength: 10, +}; + +export const mockRadioInputDeprecated = { + title: 'Has siblings', + description: 'Do you have any siblings?', + enum: ['yes', 'no'], + 'x-jsf-presentation': { + inputType: 'radio', + options: [ + { + label: 'Yes', + value: 'yes', + }, + { + label: 'No', + value: 'no', + }, + ], + }, +}; + +export const mockRadioInput = { + title: 'Has siblings', + description: 'Do you have any siblings?', + oneOf: [ + { + const: 'yes', + title: 'Yes', + }, + { + const: 'no', + title: 'No', + }, + ], + 'x-jsf-presentation': { + inputType: 'radio', + }, + type: 'string', +}; +export const mockRadioInputOptional = { + title: 'Has car', + description: 'Do you have a car? (optional field, check oneOf)', + oneOf: [ + { + const: null, // The option is excluded from the jsf options. + title: 'N/A', + }, + { + const: 'yes', + title: 'Yes', + }, + { + const: 'no', + title: 'No', + }, + ], + 'x-jsf-presentation': { + inputType: 'radio', + }, + type: ['string', 'null'], +}; + +export const mockRadioCardExpandableInput = { + title: 'Experience level', + description: + 'Please select the experience level that aligns with this role based on the job description (not the employees overall experience)', + oneOf: [ + { + const: 'junior', + title: 'Junior level', + description: + 'Entry level employees who perform tasks under the supervision of a more experienced employee.', + }, + { + const: 'mid', + title: 'Mid level', + description: + 'Employees who perform tasks with a good degree of autonomy and/or with coordination and control functions.', + }, + { + const: 'senior', + title: 'Senior level', + description: + 'Employees who perform tasks with a high degree of autonomy and/or with coordination and control functions.', + }, + ], + 'x-jsf-presentation': { + inputType: 'radio', + variant: 'card-expandable', + }, + type: 'string', +}; + +export const mockRadioCardInput = { + title: 'Payment method', + description: 'Chose how you want to be paid', + oneOf: [ + { + const: 'cc', + title: 'Credit Card', + description: 'Plastic money, which is still money', + }, + { + const: 'cash', + title: 'Cash', + description: 'Rules Everything Around Me', + }, + ], + 'x-jsf-presentation': { + inputType: 'radio', + variant: 'card', + }, + type: 'string', +}; + +export const mockSelectInputSoloDeprecated = { + title: 'Benefits (solo)', + description: 'Life Insurance', + items: { + enum: ['Medical Insurance, Health Insurance', 'Travel Bonus'], + }, + 'x-jsf-presentation': { + inputType: 'select', + options: [ + { + label: 'Medical Insurance', + value: 'Medical Insurance', + }, + { + label: 'Health Insurance', + value: 'Health Insurance', + }, + { + label: 'Travel Bonus', + value: 'Travel Bonus', + disabled: true, + }, + ], + placeholder: 'Select...', + }, +}; + +export const mockSelectInputMultipleDeprecated = { + ...mockSelectInputSoloDeprecated, + title: 'Benefits (multiple)', + type: 'array', +}; + +export const mockSelectInputSolo = { + title: 'Browsers (solo)', + description: 'This solo select also includes a disabled option.', + type: 'string', + oneOf: [ + { + const: 'chr', + title: 'Chrome', + }, + { + const: 'ff', + title: 'Firefox', + }, + { + const: 'ie', + title: 'Internet Explorer', + disabled: true, + }, + ], + 'x-jsf-presentation': { + inputType: 'select', + }, +}; + +export const mockSelectInputMultiple = { + title: 'Browsers (multiple)', + description: 'This multi-select also includes a disabled option.', + type: 'array', + uniqueItems: true, + items: { + anyOf: [ + { + const: 'chr', + title: 'Chrome', + }, + { + const: 'ff', + title: 'Firefox', + }, + { + value: 'ie', + label: 'Internet Explorer', + disabled: true, + }, + ], + }, + 'x-jsf-presentation': { + inputType: 'select', + }, +}; + +export const mockSelectInputMultipleOptional = { + ...mockSelectInputMultiple, + title: 'Browsers (multiple) (optional)', + description: 'This optional multi-select also includes a disabled option.', + type: ['array', 'null'], +}; + +export const mockDateInput = { + 'x-jsf-presentation': { + inputType: 'date', + maxDate: '2022-03-01', + minDate: '1922-03-01', + }, + title: 'Birthdate', + type: 'string', + format: 'date', + maxLength: 10, +}; + +export const mockFileInput = { + description: 'File Input Description', + 'x-jsf-presentation': { + inputType: 'file', + accept: '.png,.jpg,.jpeg,.pdf', + maxFileSize: 20480, + fileDownload: 'http://some.domain.com/file-name.pdf', + fileName: 'My File', + }, + title: 'File Input', + type: 'string', +}; + +export const mockFileInputWithSkippable = { + ...mockFileInput, + 'x-jsf-presentation': { + ...mockFileInput['x-jsf-presentation'], + skippableLabel: 'This document does not apply to my profile.', + }, +}; + +export const mockFileInputWithAllowLaterUpload = { + ...mockFileInput, + title: 'File skippable', + 'x-jsf-presentation': { + ...mockFileInput['x-jsf-presentation'], + skippableLabel: "I don't have this document yet.", + description: + 'File input, with attribute "allowLaterUpload". This tells the API to mark the file as skipped so that it is asked again later in the process.', + allowLaterUpload: true, + }, +}; + +export const mockFieldset = { + title: 'Fieldset title', + description: 'Fieldset description', + 'x-jsf-presentation': { + inputType: 'fieldset', + }, + properties: { + id_number: mockTextInput, + tabs: mockNumberInput, + }, + required: ['id_number'], + type: 'object', +}; + +export const mockFocusedFieldset = { + title: 'Focused fieldset title', + description: 'Focused fieldset description', + 'x-jsf-presentation': { + inputType: 'fieldset', + variant: 'focused', + }, + properties: { + id_number: mockTextInput, + tabs: mockNumberInput, + }, + required: ['id_number'], + type: 'object', +}; + +export const mockNestedFieldset = { + title: 'Nested fieldset title', + description: 'Nested fieldset description', + 'x-jsf-presentation': { + inputType: 'fieldset', + }, + properties: { + innerFieldset: mockFieldset, + }, + type: 'object', +}; + +export const mockGroupArrayInput = { + items: { + properties: { + birthdate: { + description: 'Enter your child’s date of birth', + format: 'date', + 'x-jsf-presentation': { + inputType: 'date', + }, + title: 'Child Birthdate', + type: 'string', + maxLength: 255, + }, + full_name: { + description: 'Enter your child’s full name', + 'x-jsf-presentation': { + inputType: 'text', + }, + title: 'Child Full Name', + type: 'string', + maxLength: 255, + }, + sex: { + description: + 'We know sex is non-binary but for insurance and payroll purposes, we need to collect this information.', + enum: ['female', 'male'], + 'x-jsf-presentation': { + inputType: 'radio', + options: [ + { + label: 'Male', + value: 'male', + }, + { + label: 'Female', + value: 'female', + }, + ], + }, + title: 'Child Sex', + }, + }, + 'x-jsf-order': ['full_name', 'birthdate', 'sex'], + required: ['full_name', 'birthdate', 'sex'], + type: 'object', + }, + 'x-jsf-presentation': { + inputType: 'group-array', + addFieldText: 'Add new field', + }, + title: 'Child details', + description: 'Add the dependents you claim below', + type: 'array', +}; + +const simpleGroupArrayInput = { + items: { + properties: { + full_name: { + description: 'Enter your child’s full name', + 'x-jsf-presentation': { + inputType: 'text', + }, + title: 'Child Full Name', + type: 'string', + maxLength: 255, + }, + }, + required: ['full_name'], + type: 'object', + }, + 'x-jsf-presentation': { + inputType: 'group-array', + }, + title: 'Child names', + description: 'Add the dependents names', + type: 'array', +}; + +export const mockOptionalGroupArrayInput = { + ...mockGroupArrayInput, + title: 'Child details (optional)', + description: + 'This is an optional group-array. For a better UX, this Component asks a Yes/No question before allowing to add new field entries.', +}; + +export const mockEmailInput = { + title: 'Email address', + description: 'Enter your email address', + maxLength: 255, + format: 'email', + 'x-jsf-presentation': { + inputType: 'email', + }, + type: 'string', +}; + +export const mockCheckboxInput = { + const: 'Permanent', + description: 'I acknowledge that all employees in France will be hired on indefinite contracts.', + 'x-jsf-presentation': { + inputType: 'checkbox', + }, + title: 'Contract duration', + type: 'string', +}; + +/** + * Compose a schema with lower chance of human error + * @param {Object} schema version + * @returns {Object} A JSON schema + * @example + * JSONSchemaBuilder().addInput({ + id_number: mockTextInput, + }) + .build(); + */ +export function JSONSchemaBuilder() { + return { + addInput: function addInput(input) { + this.properties = { + ...this.properties, + ...input, + }; + return this; + }, + setRequiredFields: function setRequiredFields(required) { + this.requiredFields = required; + return this; + }, + setOrder: function setOrder(order) { + this['x-jsf-order'] = order; + return this; + }, + addAnyOf: function addAnyOf(items) { + this.anyOf = items; + + return this; + }, + addAllOf: function addAllOf(items) { + this.allOf = items; + + return this; + }, + addCondition: function addCondition(ifCondition, thenBranch, elseBranch) { + this.if = ifCondition; + this.then = thenBranch; + this.else = elseBranch; + return this; + }, + build: function build() { + return { + type: 'object', + additionalProperties: false, + properties: this.properties, + ...(this['x-jsf-order'] ? { 'x-jsf-order': this['x-jsf-order'] } : {}), + required: this.requiredFields || [], + anyOf: this.anyOf, + allOf: this.allOf, + if: this.if, + then: this.then, + else: this.else, + }; + }, + }; +} + +// ------------------------------------- +// --------- Schemas pre-built --------- +// ------------------------------------- + +export const schemaWithoutInputTypes = { + properties: { + a_string: { + title: 'A string -> text', + type: 'string', + }, + a_string_oneOf: { + title: 'A string with oneOf -> radio', + type: 'string', + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + }, + a_string_email: { + title: 'A string with format:email -> email', + type: 'string', + format: 'email', + }, + a_string_date: { + title: 'A string with format:email -> date', + type: 'string', + format: 'date', + }, + a_string_file: { + title: 'A string with format:data-url -> file', + type: 'string', + format: 'data-url', + }, + a_number: { + title: 'A number -> number', + type: 'number', + }, + a_integer: { + title: 'A integer -> number', + type: 'integer', + }, + a_boolean: { + title: 'A boolean -> checkbox', + type: 'boolean', + }, + a_object: { + title: 'An object -> fieldset', + type: 'object', + properties: { + foo: { type: 'string' }, + bar: { type: 'string' }, + }, + }, + a_array_items: { + title: 'An array items.anyOf -> select', + type: 'array', + items: { + anyOf: [ + { + const: 'chr', + title: 'Chrome', + }, + { + const: 'ff', + title: 'Firefox', + }, + { + value: 'ie', + label: 'Internet Explorer', + }, + ], + }, + }, + a_array_properties: { + title: 'An array items.properties -> group-array', + items: { + properties: { + role: { title: 'Role', type: 'string' }, + years: { title: 'Years', type: 'number' }, + }, + }, + type: 'array', + }, + a_void: { + title: 'A void -> text', + description: 'Given no type, returns text', + }, + }, + required: ['a_array_properties'], +}; + +export const schemaWithoutTypes = { + properties: { + default: { + title: 'Default -> text', + }, + with_oneOf: { + title: 'With oneOf -> radio', + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + }, + with_email: { + title: 'With format:email -> email', + format: 'email', + }, + with_object: { + title: 'With properties -> fieldset', + properties: { + foo: {}, + bar: {}, + }, + }, + with_items_anyOf: { + title: 'With items.anyOf -> select', + items: { + anyOf: [ + { + const: 'chr', + title: 'Chrome', + }, + { + const: 'ff', + title: 'Firefox', + }, + { + value: 'ie', + label: 'Internet Explorer', + }, + ], + }, + }, + with_items_properties: { + title: 'With items.properties -> group-array', + items: { + properties: { + role: { title: 'Role' }, + years: { title: 'Years' }, + }, + }, + }, + }, +}; + +export const schemaInputTypeText = JSONSchemaBuilder() + .addInput({ + id_number: mockTextInput, + }) + .setRequiredFields(['id_number']) + .build(); + +export const schemaInputWithStatement = JSONSchemaBuilder() + .addInput({ + bonus: { + title: 'Bonus', + 'x-jsf-presentation': { + inputType: 'text', + statement: { + description: 'This is a custom statement message.', + inputType: 'statement', + severity: 'info', + }, + }, + }, + }) + .addInput({ + a_or_b: { + title: 'A dropdown', + description: 'Some options to chose from', + items: { + enum: ['A', 'B'], + }, + 'x-jsf-presentation': { + inputType: 'select', + options: [ + { + label: 'A', + value: 'A', + }, + { + label: 'B', + value: 'B', + }, + ], + placeholder: 'Select...', + statement: { + description: 'This is another statement message, but more severe.', + inputType: 'statement', + severity: 'warning', + }, + }, + }, + }) + .build(); + +export const schemaInputWithStatementDeprecated = JSONSchemaBuilder() + .addInput({ + bonus: { + title: 'Bonus', + presentation: { + inputType: 'text', + statement: { + description: 'This is a custom statement message.', + inputType: 'statement', + severity: 'info', + }, + }, + }, + }) + .addInput({ + a_or_b: { + title: 'A dropdown', + description: 'Some options to chose from', + items: { + enum: ['A', 'B'], + }, + presentation: { + inputType: 'select', + options: [ + { + label: 'A', + value: 'A', + }, + { + label: 'B', + value: 'B', + }, + ], + placeholder: 'Select...', + statement: { + description: 'This is another statement message, but more severe.', + inputType: 'statement', + severity: 'warning', + }, + }, + }, + }) + .build(); + +export const schemaInputWithExtra = JSONSchemaBuilder() + .addInput({ + bonus: { + title: 'Bonus', + 'x-jsf-presentation': { + inputType: 'text', + description: 'Remote lives around core values across the company.', + extra: `They are: + +

You can read more at our public handbook. They are also referred as KOETA.

+ `, + }, + }, + }) + .build(); + +export const schemaInputWithCustomDescription = JSONSchemaBuilder() + .addInput({ + other: { + title: 'Other', + 'x-jsf-presentation': { + inputType: 'text', + description: 'Some other information might still be relevant for you.', + }, + type: 'string', + }, + }) + .build(); + +export const schemaInputDeprecated = JSONSchemaBuilder() + .addInput({ + age_empty: { + title: 'Age (Empty) (Deprecated)', + 'x-jsf-presentation': { + inputType: 'number', + description: 'What is your age?', + deprecated: { + description: 'Field deprecated empty.', + }, + }, + deprecated: true, + readOnly: true, + type: 'number', + }, + }) + .addInput({ + age_filled: { + title: 'Age (Filled) (Deprecated)', + 'x-jsf-presentation': { + inputType: 'number', + description: 'What is your age?', + deprecated: { + description: 'Field deprecated and readOnly with a default value.', + }, + }, + default: 18, + deprecated: true, + readOnly: true, + type: 'number', + }, + }) + .addInput({ + age_editable: { + title: 'Age (Editable) (Deprecated)', + 'x-jsf-presentation': { + inputType: 'number', + description: 'What is your age?', + deprecated: { + description: 'Field deprecated but editable.', + }, + }, + deprecated: true, + type: 'number', + }, + }) + .build(); + +/** @deprecated */ +export const schemaInputTypeRadioDeprecated = JSONSchemaBuilder() + .addInput({ + has_siblings: mockRadioInputDeprecated, + }) + .setRequiredFields(['has_siblings']) + .build(); +export const schemaInputTypeRadio = JSONSchemaBuilder() + .addInput({ + has_siblings: mockRadioInput, + }) + .setRequiredFields(['has_siblings']) + .build(); + +export const schemaInputTypeRadioRequiredAndOptional = JSONSchemaBuilder() + .addInput({ + has_siblings: mockRadioInput, + has_car: mockRadioInputOptional, + }) + .setRequiredFields(['has_siblings']) + .build(); + +export const schemaInputTypeRadioCard = JSONSchemaBuilder() + .addInput({ + experience_level: mockRadioCardExpandableInput, + payment_method: mockRadioCardInput, + }) + .setRequiredFields(['experience_level']) + .build(); + +/** @deprecated */ +export const schemaInputTypeSelectSoloDeprecated = JSONSchemaBuilder() + .addInput({ + benefits: mockSelectInputSoloDeprecated, + }) + .setRequiredFields(['benefits']) + .build(); +export const schemaInputTypeSelectSolo = JSONSchemaBuilder() + .addInput({ + browsers: mockSelectInputSolo, + }) + .setRequiredFields(['browsers']) + .build(); + +/** @deprecated */ +export const schemaInputTypeSelectMultipleDeprecated = JSONSchemaBuilder() + .addInput({ + benefits_multi: mockSelectInputMultipleDeprecated, + }) + .setRequiredFields(['benefits_multi']) + .build(); +export const schemaInputTypeSelectMultiple = JSONSchemaBuilder() + .addInput({ + browsers_multi: mockSelectInputMultiple, + }) + .setRequiredFields(['browsers_multi']) + .build(); + +export const schemaInputTypeSelectMultipleOptional = JSONSchemaBuilder() + .addInput({ + browsers_multi_optional: mockSelectInputMultipleOptional, + }) + .build(); + +export const schemaInputTypeNumber = JSONSchemaBuilder() + .addInput({ + tabs: mockNumberInput, + }) + .setRequiredFields(['tabs']) + .build(); + +export const schemaInputTypeNumberWithPercentage = JSONSchemaBuilder() + .addInput({ + shares: mockNumberInputWithPercentage, + }) + .setRequiredFields(['shares']) + .build(); + +export const schemaInputTypeDate = JSONSchemaBuilder() + .addInput({ + birthdate: mockDateInput, + }) + .setRequiredFields(['birthdate']) + .build(); + +export const schemaInputTypeEmail = JSONSchemaBuilder() + .addInput({ + email_address: mockEmailInput, + }) + .setRequiredFields(['email_address']) + .build(); + +export const schemaInputTypeFile = JSONSchemaBuilder() + .addInput({ + a_file: mockFileInput, + }) + .setRequiredFields(['a_file']) + .build(); + +export const schemaInputTypeFileWithSkippable = JSONSchemaBuilder() + .addInput({ + b_file: mockFileInputWithSkippable, + }) + .build(); + +export const schemaInputTypeFieldset = JSONSchemaBuilder() + .addInput({ + a_fieldset: mockFieldset, + }) + .setRequiredFields(['a_fieldset']) + .build(); + +export const schemaInputTypeFocusedFieldset = JSONSchemaBuilder() + .addInput({ + focused_fieldset: mockFocusedFieldset, + }) + .setRequiredFields(['focused_fieldset']) + .build(); + +export const schemaInputTypeGroupArray = JSONSchemaBuilder() + .addInput({ + dependent_details: mockGroupArrayInput, + optional_dependent_details: mockOptionalGroupArrayInput, + }) + .setRequiredFields(['dependent_details']) + .build(); + +export const schemaInputTypeCheckbox = JSONSchemaBuilder() + .addInput({ + contract_duration: mockCheckboxInput, + contract_duration_checked: { + ...mockCheckboxInput, + title: 'Checkbox (checked by default)', + default: 'Permanent', + }, + }) + .setRequiredFields(['contract_duration']) + .build(); + +export const schemaInputTypeCheckboxBooleans = JSONSchemaBuilder() + .addInput({ + boolean_empty: { + title: 'It is christmas', + description: 'This one is optional.', + type: 'boolean', + 'x-jsf-presentation': { + inputType: 'checkbox', + }, + }, + boolean_required: { + title: 'Is it rainy (required)', + description: 'This one is required. Is must have const: true to work properly.', + type: 'boolean', + const: true, // Must be explicit that `true` (checked) is the only accepted value. + 'x-jsf-presentation': { + inputType: 'checkbox', + }, + }, + boolean_checked: { + title: 'It is sunny (Default checked)', + description: 'This is checked by default thanks to `default: true`.', + type: 'boolean', + default: true, + 'x-jsf-presentation': { + inputType: 'checkbox', + }, + }, + }) + .setRequiredFields(['boolean_required']) + .build(); + +export const schemaCustomErrorMessageByField = { + properties: { + tabs: { + title: 'Tabs', + description: 'How many open tabs do you have?', + 'x-jsf-presentation': { + inputType: 'number', + position: 0, + }, + minimum: 1, + maximum: 99, + type: 'number', + 'x-jsf-errorMessage': { + required: 'This is required.', + minimum: 'You must have at least 1 open tab.', + maximum: 'Your browser does not support more than 99 tabs.', + }, + }, + }, + required: ['tabs'], +}; + +// The custom error message is below at jsfConfigForErrorMessageSpecificity +export const schemaForErrorMessageSpecificity = { + properties: { + weekday: { + title: 'Weekday', + description: "This text field has the traditional error message. 'Required field'", + type: 'string', + presentation: { inputType: 'text' }, + }, + day: { + title: 'Day', + type: 'number', + description: + 'The remaining fields are numbers and were customized to say "This cannot be empty." instead of "Required field".', + + maximum: 31, + presentation: { inputType: 'number' }, + }, + month: { + title: 'Month', + type: 'number', + minimum: 1, + maximum: 12, + presentation: { inputType: 'number' }, + }, + year: { + title: 'Year', + description: + "This number field has a custom error message declared in the json schema, which has a higher specificity than the one declared in createHeadlessForm's configuration.", + type: 'number', + presentation: { inputType: 'number' }, + 'x-jsf-errorMessage': { + required: 'The year is mandatory.', + }, + minimum: 1900, + maximum: 2023, + }, + }, + required: ['weekday', 'day', 'month', 'year'], +}; + +export const jsfConfigForErrorMessageSpecificity = { + inputTypes: { + number: { + errorMessage: { + required: 'This cannot be empty.', + }, + }, + }, +}; + +export const schemaWithPositionDeprecated = JSONSchemaBuilder() + .addInput({ + age: { + title: 'age', + 'x-jsf-presentation': { inputType: 'number', position: 1 }, + }, + street: { + title: 'street', + 'x-jsf-presentation': { inputType: 'fieldset', position: 2 }, + properties: { + line_one: { + title: 'Street', + 'x-jsf-presentation': { inputType: 'text', position: 0 }, + }, + postal_code: { + title: 'Postal code', + 'x-jsf-presentation': { inputType: 'text', position: 2 }, + }, + number: { + title: 'Number', + 'x-jsf-presentation': { inputType: 'number', position: 1 }, + }, + }, + }, + username: { + title: 'Username', + 'x-jsf-presentation': { inputType: 'text', position: 0 }, + }, + }) + .build(); + +export const schemaWithOrderKeyword = JSONSchemaBuilder() + .addInput({ + age: { + title: 'Age', + 'x-jsf-presentation': { inputType: 'number' }, + }, + street: { + title: 'Street', + 'x-jsf-presentation': { inputType: 'fieldset' }, + properties: { + line_one: { + title: 'Street', + 'x-jsf-presentation': { inputType: 'text' }, + }, + postal_code: { + title: 'Postal code', + 'x-jsf-presentation': { inputType: 'text' }, + }, + number: { + title: 'Number', + 'x-jsf-presentation': { inputType: 'number' }, + }, + }, + 'x-jsf-order': ['line_one', 'number', 'postal_code'], + }, + username: { + title: 'Username', + 'x-jsf-presentation': { inputType: 'text' }, + }, + }) + .setOrder(['username', 'age', 'street']) + .build(); + +export const schemaDynamicValidationConst = JSONSchemaBuilder() + .addInput({ + a_fieldset: mockFieldset, + a_group_array: simpleGroupArrayInput, + validate_tabs: { + title: 'Should "Tabs" value be required?', + description: 'Toggle this radio for changing the validation of the fieldset bellow', + oneOf: [ + { + title: 'Yes', + value: 'yes', + }, + { + title: 'No', + value: 'no', + }, + ], + 'x-jsf-presentation': { + inputType: 'radio', + }, + }, + mandatory_group_array: { + title: 'Add required group array field', + description: 'Toggle this radio for displaying a mandatory group array field', + oneOf: [ + { + title: 'Yes', + value: 'yes', + }, + { + title: 'No', + value: 'no', + }, + ], + 'x-jsf-presentation': { + inputType: 'radio', + }, + }, + }) + .addAllOf([ + { + if: { + properties: { + mandatory_group_array: { + const: 'yes', + }, + }, + required: ['mandatory_group_array'], + }, + then: { + required: ['a_group_array'], + }, + else: { + properties: { + a_group_array: false, + }, + }, + }, + ]) + .addCondition( + { + properties: { + validate_tabs: { + const: 'yes', + }, + }, + required: ['validate_tabs'], + }, + { + properties: { + a_fieldset: { + required: ['id_number', 'tabs'], + }, + }, + } + ) + .setRequiredFields(['a_fieldset', 'validate_tabs', 'mandatory_group_array']) + .setOrder(['validate_tabs', 'a_fieldset', 'mandatory_group_array', 'a_group_array']) + .build(); + +export const schemaDynamicValidationMinimumMaximum = JSONSchemaBuilder() + .addInput({ + a_number: mockNumberInput, + a_conditional_text: mockTextInput, + }) + .addCondition( + { + properties: { + a_number: { + minimum: 1, + }, + }, + required: ['a_number'], + }, + { + if: { + properties: { + a_number: { + maximum: 10, + }, + }, + required: ['a_number'], + }, + then: { + required: [], + }, + else: { + required: ['a_conditional_text'], + }, + }, + { + required: ['a_conditional_text'], + } + ) + .build(); + +export const schemaDynamicValidationMinLengthMaxLength = JSONSchemaBuilder() + .addInput({ + a_text: mockTextInput, + a_conditional_text: mockTextInput, + }) + .addCondition( + // if a_text is between 3 and 5 chars, a_conditional_text is optional. + { + properties: { + a_text: { + minLength: 3, + maxLength: 5, + }, + }, + required: ['a_text'], + }, + { + required: [], + }, + { + required: ['a_conditional_text'], + } + ) + .build(); + +export const schemaDynamicValidationContains = JSONSchemaBuilder() + .addInput({ + a_fieldset: mockFieldset, + validate_fieldset: { + title: 'Fieldset validation', + type: 'array', + description: 'Select what fieldset fields are required', + items: { + enum: ['all', 'id_number'], + }, + 'x-jsf-presentation': { + inputType: 'select', + options: [ + { + label: 'All', + value: 'all', + }, + { + label: 'ID Number', + value: 'id_number', + }, + ], + placeholder: 'Select...', + }, + }, + }) + .addCondition( + { + properties: { + validate_fieldset: { + contains: { + pattern: '^all$', + }, + }, + }, + required: ['validate_fieldset'], + }, + { + properties: { + a_fieldset: { + required: ['id_number', 'tabs'], + }, + }, + } + ) + .setRequiredFields(['a_fieldset', 'validate_fieldset']) + .setOrder(['validate_fieldset', 'a_fieldset']) + .build(); + +export const schemaAnyOfValidation = JSONSchemaBuilder() + .addInput({ + field_a: { + ...mockTextInput, + title: 'Field A', + description: 'Field A is needed if B and C are empty', + }, + field_b: { + ...mockTextInput, + title: 'Field B', + description: 'Field B is needed if A is empty and C is not empty', + }, + field_c: { + ...mockTextInput, + title: 'Field C', + description: 'Field C is needed if A is empty and B is not empty', + }, + }) + .addAnyOf([ + { + required: ['field_a'], + }, + { + required: ['field_b', 'field_c'], + }, + ]) + .build(); + +export const schemaWithConditionalPresentationProperties = JSONSchemaBuilder() + .addInput({ + mock_radio: mockRadioInput, + }) + .addAllOf([ + { + if: { + properties: { + mock_radio: { + const: 'no', + }, + }, + required: ['mock_radio'], + }, + then: { + properties: { + mock_radio: { + 'x-jsf-presentation': { + statement: { + description: 'conditional statement markup', + severity: 'info', + }, + }, + }, + }, + }, + else: { + properties: { + 'x-jsf-presentation': { + mock_radio: null, + }, + }, + }, + }, + ]) + .setRequiredFields(['mock_radio']) + .build(); + +export const schemaWithConditionalReadOnlyProperty = JSONSchemaBuilder() + .addInput({ field_a: mockRadioInput }) + .addInput({ field_b: mockTextInput }) + .addAllOf([ + { + if: { + properties: { + field_a: { + const: 'yes', + }, + }, + required: ['field_a'], + }, + then: { + properties: { + field_b: { + readOnly: false, + }, + }, + required: ['field_b'], + }, + else: { + if: { + properties: { + field_a: { + const: 'no', + }, + }, + required: ['field_a'], + }, + then: { + properties: { + field_b: { + readOnly: true, + }, + }, + required: ['field_b'], + }, + else: { + properties: { + field_b: false, + }, + }, + }, + }, + ]) + .setRequiredFields(['field_a']) + .build(); + +export const schemaWithConditionalAcknowledgementProperty = JSONSchemaBuilder() + .addInput({ field_a: mockRadioInput }) + .addInput({ field_b: mockCheckboxInput }) + .addAllOf([ + { + if: { + properties: { + field_a: { + const: 'yes', + }, + }, + required: ['field_a'], + }, + then: { + required: ['field_b'], + }, + else: { + properties: { + field_b: false, + }, + }, + }, + ]) + .setRequiredFields(['field_a']) + .build(); + +// Note: The second conditional (field_a_wrong) is incorrect, +// it's used to test/catch the scenario where devs forget to add the if.required[] +export const schemaWithWrongConditional = JSONSchemaBuilder() + .addInput({ field_a: mockRadioInput }) + .addInput({ field_b: mockTextInput }) + .addInput({ field_a_wrong: mockRadioInput }) + .addInput({ field_b_wrong: mockTextInput }) + .addAllOf([ + { + if: { + properties: { + field_a: { + const: 'yes', + }, + }, + required: ['field_a'], + }, + then: { + required: ['field_b'], + }, + else: { + properties: { + field_b: false, + }, + }, + }, + { + if: { + properties: { + field_a_wrong: { + const: 'yes', + }, + }, + // it's missing this "required" keyword, for field_b_wrong to be visible. + // required: ['field_a_wrong'], + }, + then: { + required: ['field_b_wrong'], + }, + else: { + properties: { + field_b_wrong: false, + }, + }, + }, + ]) + .setRequiredFields(['field_a', 'field_a_wrong']) + .build(); + +export const schemaFieldsetScopedCondition = { + additionalProperties: false, + properties: { + child: { + type: 'object', + title: 'Child details', + description: + 'In the JSON Schema, you will notice the if/then/else is inside the property, not in the root.', + 'x-jsf-presentation': { + inputType: 'fieldset', + }, + properties: { + has_child: { + description: 'If yes, it will show its age.', + maximum: 100, + 'x-jsf-presentation': { + inputType: 'radio', + options: [ + { + label: 'Yes', + value: 'yes', + }, + { + label: 'No', + value: 'no', + }, + ], + }, + title: 'Do you have a child?', + type: 'number', + }, + age: { + description: 'This age is required, but the "age" at the root level is still optional.', + 'x-jsf-presentation': { + inputType: 'number', + }, + title: 'Age', + type: 'number', + }, + passport_id: { + description: 'Passport ID is optional', + 'x-jsf-presentation': { + inputType: 'text', + }, + title: 'Passport ID', + type: 'string', + }, + }, + required: ['has_child'], + allOf: [ + { + if: { + properties: { + has_child: { + const: 'yes', + }, + }, + required: ['has_child'], + }, + then: { + required: ['age'], + }, + else: { + properties: { + age: false, + passport_id: false, + }, + }, + }, + ], + }, + age: { + type: 'number', + title: 'Age', + 'x-jsf-presentation': { + inputType: 'number', + description: 'This field is optional, always.', + }, + }, + }, + required: ['child'], + type: 'object', +}; + +export const schemaWorkSchedule = { + type: 'object', + properties: { + employee_schedule: { + title: 'Employee Schedule', + 'x-jsf-presentation': { + inputType: 'fieldset', + position: 0, + }, + properties: { + schedule_type: { + type: 'string', + title: 'Employee Schedule Type', + oneOf: [ + { const: 'flexible', title: "Employee's hours are flexible" }, + { + const: 'core_business_hours', + title: "Employee works employer's core business hours", + }, + { const: 'fixed_hours', title: 'Employee works fixed hours' }, + ], + 'x-jsf-presentation': { + inputType: 'select', + position: 0, + }, + }, + daily_schedule: { + type: 'array', + items: { + type: 'object', + properties: { + day: { + type: 'string', + enum: [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ], + }, + start_time: { + type: 'string', + pattern: '^([01]\\d|2[0-3]):([0-5]\\d)$', + }, + end_time: { + type: 'string', + pattern: '^([01]\\d|2[0-3]):([0-5]\\d)$', + }, + hours: { + type: 'number', + minimum: 0, + }, + break_duration_minutes: { + type: 'integer', + minimum: 0, + }, + }, + required: ['day', 'start_time', 'end_time', 'hours', 'break_duration_minutes'], + }, + 'x-jsf-presentation': { + position: 1, + inputType: 'work-schedule', + }, + default: [], + }, + work_hours_per_week: { + type: 'number', + title: 'Work Hours Per Week', + maximum: 50, + minimum: 1, + 'x-jsf-errorMessage': { + minimum: 'You must enter work hours to equal more than 0.', + }, + 'x-jsf-presentation': { + inputType: 'number', + position: 2, + }, + }, + exclude_breaks_in_work_hours: { + const: true, + readOnly: true, + 'x-jsf-presentation': { + inputType: 'hidden', + }, + type: 'boolean', + }, + }, + allOf: [ + { + if: { + properties: { + schedule_type: { + enum: ['flexible', 'core_business_hours', 'fixed_hours'], + }, + }, + required: ['schedule_type'], + }, + then: { + required: ['work_hours_per_week'], + }, + else: { + properties: { + work_hours_per_week: false, + }, + }, + }, + { + if: { + properties: { + schedule_type: { + enum: ['core_business_hours', 'fixed_hours'], + }, + }, + required: ['schedule_type'], + }, + then: { + required: ['daily_schedule'], + }, + else: { + properties: { + daily_schedule: false, + }, + }, + }, + { + if: { + properties: { + schedule_type: { + const: 'flexible', + }, + }, + required: ['schedule_type'], + }, + then: { + required: ['work_hours_per_week'], + properties: { + work_hours_per_week: { + readOnly: false, + }, + }, + }, + }, + { + if: { + properties: { + schedule_type: { + const: 'core_business_hours', + }, + }, + required: ['schedule_type'], + }, + then: { + required: ['work_hours_per_week'], + properties: { + daily_schedule: { + title: 'Core business hours', + default: [ + { + day: 'monday', + start_time: '10:00', + end_time: '17:30', + hours: 8.5, + break_duration_minutes: 60, + }, + { + day: 'wednesday', + start_time: '10:00', + end_time: '17:30', + hours: 7.5, + break_duration_minutes: 45, + }, + { + day: 'friday', + start_time: '09:00', + end_time: '17:30', + hours: 8.5, + break_duration_minutes: 45, + }, + ], + }, + work_hours_per_week: { + readOnly: false, + }, + }, + }, + }, + { + if: { + properties: { + schedule_type: { + const: 'fixed_hours', + }, + }, + required: ['schedule_type'], + }, + then: { + properties: { + daily_schedule: { + title: 'Work hours', + default: [ + { + day: 'monday', + start_time: '10:00', + end_time: '17:30', + hours: 8.5, + break_duration_minutes: 60, + }, + { + day: 'wednesday', + start_time: '10:00', + end_time: '17:30', + hours: 7.5, + break_duration_minutes: 45, + }, + { + day: 'saturday', + start_time: '09:00', + end_time: '17:30', + hours: 8.5, + break_duration_minutes: 45, + }, + ], + }, + work_hours_per_week: { + readOnly: true, + }, + }, + }, + }, + ], + required: ['schedule_type'], + type: 'object', + }, + }, + required: ['employee_schedule'], + allOf: [], +}; + +export const schemaWithCustomValidations = { + properties: { + work_hours_per_week: { + title: 'Work hours per week', + 'x-jsf-presentation': { + inputType: 'number', + }, + minimum: 1, + maximum: 40, + type: 'number', + }, + available_pto: { + 'x-jsf-presentation': { + inputType: 'number', + }, + title: 'Number of paid time off days', + type: 'number', + }, + }, + 'x-jsf-order': ['work_hours_per_week', 'available_pto'], + required: ['work_hours_per_week', 'available_pto'], +}; + +export const schemaWithCustomValidationsAndConditionals = { + properties: { + work_schedule: { + oneOf: [ + { const: 'full_time', title: 'Full-time' }, + { const: 'part_time', title: 'Part-time' }, + ], + 'x-jsf-presentation': { + inputType: 'radio', + }, + type: 'string', + title: 'Type of employee', + }, + work_hours_per_week: { + title: 'Work hours per week', + description: 'Please indicate the number of hours the employee will work per week.', + 'x-jsf-presentation': { + inputType: 'number', + }, + minimum: 1, + maximum: 40, + type: 'number', + }, + annual_gross_salary: { + title: 'Annual gross salary', + 'x-jsf-presentation': { + inputType: 'money', + currency: 'EUR', + }, + $comment: 'The minimum is dynamically calculated with jsf.', + type: ['integer', 'null'], + }, + hourly_gross_salary: { + title: 'Hourly gross salary', + 'x-jsf-presentation': { + inputType: 'money', + currency: 'EUR', + }, + $comment: 'The minimum is dynamically calculated with jsf.', + type: ['integer', 'null'], + }, + }, + 'x-jsf-order': [ + 'work_schedule', + 'work_hours_per_week', + 'annual_gross_salary', + 'hourly_gross_salary', + ], + required: ['work_schedule', 'work_hours_per_week'], + allOf: [ + { + if: { + properties: { + work_schedule: { + const: 'full_time', + }, + }, + required: ['work_schedule'], + }, + then: { + properties: { + work_hours_per_week: { + minimum: 36, + maximum: 40, + 'x-jsf-errorMessage': { + minimum: 'Must be at least 36 hours per week.', + maximum: 'Must be no more than 40 hours per week.', + }, + }, + hourly_gross_salary: false, + }, + required: ['annual_gross_salary'], + }, + else: { + required: ['hourly_gross_salary'], + properties: { + annual_gross_salary: false, + work_hours_per_week: { + minimum: 1, + maximum: 35, + 'x-jsf-errorMessage': { + minimum: 'Must be at least 1 hour per week.', + maximum: 'Must be no more than 35 hours per week.', + }, + }, + }, + }, + }, + ], +}; diff --git a/src/tests/internals.helpers.test.js b/src/tests/internals.helpers.test.js new file mode 100644 index 00000000..182b5cc5 --- /dev/null +++ b/src/tests/internals.helpers.test.js @@ -0,0 +1,175 @@ +import * as Yup from 'yup'; + +import { yupToFormErrors } from '../helpers'; +import { getFieldDescription, pickXKey } from '../internals/helpers'; + +describe('getFieldDescription()', () => { + it('returns no description', () => { + const descriptionField = getFieldDescription(); + expect(descriptionField).toEqual({}); + }); + + it('returns the description from the node', () => { + const node = { description: 'a description' }; + const customProperties = {}; + const descriptionField = getFieldDescription(node, customProperties); + expect(descriptionField).toEqual({ description: 'a description' }); + }); + + describe('with customProperties', () => { + it('given no match, returns no description', () => { + const node = {}; + const customProperties = { a_property: 'a_property' }; + const descriptionField = getFieldDescription(node, customProperties); + + expect(descriptionField).toEqual({}); + }); + + it('returns the description from customProperties', () => { + const node = { description: 'a description' }; + const customProperties = { description: 'a custom description' }; + + const descriptionField = getFieldDescription(node, customProperties); + + expect(descriptionField).toEqual({ description: 'a custom description' }); + }); + }); + + describe('with x-jsf-presentation attribute', () => { + it('returns x-jsf-presentation given no base description', () => { + const node = { + 'x-jsf-presentation': { description: 'a presentation description' }, + }; + const customProperties = {}; + const descriptionField = getFieldDescription(node, customProperties); + + expect(descriptionField).toEqual({ + presentation: { description: 'a presentation description' }, + }); + }); + + it('returns presentation overriding the base description', () => { + const node = { + description: 'a description', + 'x-jsf-presentation': { description: 'a presentation description' }, + }; + const customProperties = {}; + const descriptionField = getFieldDescription(node, customProperties); + + expect(descriptionField).toEqual({ + description: 'a description', + presentation: { description: 'a presentation description' }, + }); + }); + + it('returns the custom description, overriding the base and presentation description', () => { + const node = { + description: 'a description', + 'x-jsf-presentation': { description: 'a presentation description' }, + }; + const customProperties = { description: 'a custom description' }; + const descriptionField = getFieldDescription(node, customProperties); + + expect(descriptionField).toEqual({ + description: 'a custom description', + presentation: { description: 'a custom description' }, + }); + }); + }); +}); + +describe('yupToFormErrors()', () => { + it('returns an object given an YupError', () => { + const yupOpts = { abortEarly: false }; + const YupSchema = Yup.object({ + age: Yup.number().min(18, 'Too young'), + name: Yup.object({ + first: Yup.string().required(), + middle: Yup.string(), + last: Yup.string().required('Required field.'), + }), + }); + + try { + YupSchema.validateSync( + { + age: 10, + name: { first: 'Junior' }, + }, + yupOpts + ); + } catch (yupError) { + expect(yupToFormErrors(yupError)).toEqual({ + age: 'Too young', + name: { + last: 'Required field.', + }, + }); + } + }); + + it('returns nill given nill', () => { + expect(yupToFormErrors(undefined)).toEqual(undefined); + expect(yupToFormErrors(null)).toEqual(null); + }); +}); + +describe('pickXKey()', () => { + it('returns the x-jsx attribute', () => { + const schema = { + max_length: 255, + 'x-jsf-presentation': { + inputType: 'text', + }, + title: 'Address', + type: 'string', + }; + const xKey = pickXKey(schema, 'presentation'); + + expect(xKey).toEqual({ + inputType: 'text', + }); + }); + + it('returns the deprecated attribute', () => { + const schema = { + max_length: 255, + presentation: { + inputType: 'text (deprecated)', + }, + title: 'Address', + type: 'string', + }; + const xKey = pickXKey(schema, 'presentation'); + + expect(xKey).toEqual({ + inputType: 'text (deprecated)', + }); + }); + + it('return undefined given a key that is not being deprecated', () => { + const schema = { + properties: { + age: { type: 'number' }, + address: { type: 'string' }, + }, + order: ['age', 'address'], + }; + const xKey = pickXKey(schema, 'order'); + + // it's undefined because "x-jsf-order" does not exist, + // and "order" is not one of the deprecated custom keywords. + expect(xKey).toBeUndefined(); + }); + + it('returns undefined if the key is not found within an object', () => { + const schema = { + max_length: 255, + title: 'Address', + type: 'string', + }; + const xKey = pickXKey(schema, 'presentation'); + + expect(xKey).toBeUndefined(); + }); +}); diff --git a/src/tests/utils.test.jsx b/src/tests/utils.test.jsx new file mode 100644 index 00000000..74af5846 --- /dev/null +++ b/src/tests/utils.test.jsx @@ -0,0 +1,32 @@ +import { convertDiskSizeFromTo } from '../utils'; + +describe('utils', () => { + it('should convert bytes to KB', () => { + const convert = convertDiskSizeFromTo('Bytes', 'KB'); + expect(convert(1024)).toBe(1); + }); + it('should convert bytes to MB', () => { + const convert = convertDiskSizeFromTo('Bytes', 'MB'); + expect(convert(1024 * 1024)).toBe(1); + }); + + it('should convert KB to MB', () => { + const convert = convertDiskSizeFromTo('KB', 'MB'); + expect(convert(1024)).toBe(1); + }); + + it('should convert KB to Bytes', () => { + const convert = convertDiskSizeFromTo('KB', 'Bytes'); + expect(convert(1)).toBe(1024); + }); + + it('should convert MB to KB', () => { + const convert = convertDiskSizeFromTo('MB', 'KB'); + expect(convert(1)).toBe(1024); + }); + + it('should convert MB to KB', () => { + const convert = convertDiskSizeFromTo('MB', 'Bytes'); + expect(convert(1)).toBe(1048576); + }); +}); diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 00000000..d6476e99 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,65 @@ +/** + * Returns a function that converts a unit of bytes to another one. Example: convert KB to MB, or Bytes to KB. + * + * @param {String} from base unit + * @param {String} to unit to be converted to + * @returns {Function} + */ +export function convertDiskSizeFromTo(from, to) { + const units = ['bytes', 'kb', 'mb']; + + /** + * Convert the received value based on the from and to parameters + * + * @param {Number} value value to be converted + * @returns {Number} converted value + */ + return function convert(value) { + return ( + (value * Math.pow(1024, units.indexOf(from.toLowerCase()))) / + Math.pow(1024, units.indexOf(to.toLowerCase())) + ); + }; +} + +/** + * Check if a string contains HTML tags + * @param {string} str + * @returns {boolean} + */ +export function containsHTML(str = '') { + return /<[a-z][\s\S]*>/i.test(str); +} + +/** + * Wraps a string with a span with attributes, if any. + * @param {string} html Content to be wrapped + * @param {Object.} properties Object to be converted to HTML attributes + * @returns {string} + */ +export function wrapWithSpan(html, properties = {}) { + const attributes = Object.entries(properties) + .reduce((acc, [key, value]) => `${acc}${key}="${value}" `, '') + .trim(); + return `${html}`; +} + +/** + * Checks if an object contains a property with a given name. + * This util is needed because sometimes a condition coming from the schema could be something like + * if { const: null; + * "properties": { + * "someField": { + * "const": null + * } + * } + * + * And we need to check if the key exists (!!prop.const wouldn't work and this way we avoid a typeof call) + * + * @param {Object} object - object being evaluated + * @param {String} propertyName - name of the property being checked + * @returns {Boolean} + */ +export function hasProperty(object, propertyName) { + return Object.prototype.hasOwnProperty.call(object, propertyName); +} diff --git a/src/yupSchema.js b/src/yupSchema.js new file mode 100644 index 00000000..d0b29622 --- /dev/null +++ b/src/yupSchema.js @@ -0,0 +1,302 @@ +import flow from 'lodash/flow'; +import noop from 'lodash/noop'; +import { randexp } from 'randexp'; +import { string, number, boolean, object, array, mixed } from 'yup'; + +import { supportedTypes } from './internals/fields'; +import { convertDiskSizeFromTo } from './utils'; + +/** + * @typedef {import('./createHeadlessForm').FieldParameters} FieldParameters + */ + +export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd'; +export const baseString = string().trim(); + +const todayDateHint = new Date().toISOString().substring(0, 10); +const convertBytesToKB = convertDiskSizeFromTo('Bytes', 'KB'); +const convertKbBytesToMB = convertDiskSizeFromTo('KB', 'MB'); + +const yupSchemas = { + text: string().trim().nullable(), + select: string().trim().nullable(), + radio: string().trim().nullable(), + date: string() + .nullable() + .trim() + .matches( + /(?:\d){4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|1[0-9]|2[0-9]|3[0-1])/, + `Must be a valid date in ${DEFAULT_DATE_FORMAT.toLocaleLowerCase()} format. e.g. ${todayDateHint}` + ), + number: number().typeError('The value must be a number').nullable(), + file: array().nullable(), + email: string().trim().email('Please enter a valid email address').nullable(), + fieldset: object().nullable(), + checkbox: string().trim().nullable(), + checkboxBool: boolean(), + multiple: { + select: array().nullable(), + 'group-array': array().nullable(), + }, +}; + +const yupSchemasToJsonTypes = { + string: yupSchemas.text, + number: yupSchemas.number, + integer: yupSchemas.number, + object: yupSchemas.fieldset, + array: yupSchemas.multiple.select, + boolean: yupSchemas.checkboxBool, + null: noop, +}; + +function getRequiredErrorMessage(inputType, { inlineError, configError }) { + if (inlineError) return inlineError; + if (configError) return configError; + if (inputType === supportedTypes.CHECKBOX) return 'Please acknowledge this field'; + return 'Required field'; +} + +const getJsonTypeInArray = (jsonType) => + Array.isArray(jsonType) + ? jsonType.find((val) => val !== 'null') // eg ["string", "null"] // optional fields - get the lead type. + : jsonType; // eg "string" + +/** + * @param {FieldParameters} field Input fields + * @returns {Function} Yup schema + */ +export function buildYupSchema(field, config) { + const { inputType, jsonType: jsonTypeValue, errorMessage = {}, ...propertyFields } = field; + const isCheckboxBoolean = typeof propertyFields.checkboxValue === 'boolean'; + let baseSchema; + const jsonType = getJsonTypeInArray(jsonTypeValue); + const errorMessageFromConfig = config?.inputTypes?.[inputType]?.errorMessage || {}; + + if (propertyFields.multiple) { + // keep inputType while non-core are being removed #RMT-439 + baseSchema = yupSchemas.multiple[inputType] || yupSchemasToJsonTypes.array; + } else if (isCheckboxBoolean) { + baseSchema = yupSchemas.checkboxBool; + } else { + baseSchema = yupSchemas[inputType] || yupSchemasToJsonTypes[jsonType]; + } + + if (!baseSchema) { + return noop; + } + + const randomPlaceholder = propertyFields.pattern && randexp(propertyFields.pattern); + const requiredMessage = getRequiredErrorMessage(inputType, { + inlineError: errorMessage.required, + configError: errorMessageFromConfig.required, + }); + + function withRequired(yupSchema) { + if (isCheckboxBoolean) { + // note: `false` is considered a valid boolean https://github.com/jquense/yup/issues/415#issuecomment-458154168 + return yupSchema.oneOf([true], requiredMessage).required(requiredMessage); + } + return yupSchema.required(requiredMessage); + } + function withMin(yupSchema) { + return yupSchema.min( + propertyFields.minimum, + (message) => + errorMessage.minimum ?? + errorMessageFromConfig.minimum ?? + `Must be greater or equal to ${message.min}` + ); + } + + function withMinLength(yupSchema) { + return yupSchema.min( + propertyFields.minLength, + (message) => + errorMessage.minLength ?? + errorMessageFromConfig.minLength ?? + `Please insert at least ${message.min} characters` + ); + } + + function withMax(yupSchema) { + return yupSchema.max( + propertyFields.maximum, + (message) => + errorMessage.maximum ?? + errorMessageFromConfig.maximum ?? + `Must be smaller or equal to ${message.max}` + ); + } + + function withMaxLength(yupSchema) { + return yupSchema.max( + propertyFields.maxLength, + (message) => + errorMessage.maxLength ?? + errorMessageFromConfig.maxLength ?? + `Please insert up to ${message.max} characters` + ); + } + + function withMatches(yupSchema) { + return yupSchema.matches( + propertyFields.pattern, + () => + errorMessage.pattern ?? + errorMessageFromConfig.pattern ?? + `Must have a valid format. E.g. ${randomPlaceholder}` + ); + } + + function withMaxFileSize(yupSchema) { + return yupSchema.test( + 'isValidFileSize', + errorMessage.maxFileSize ?? + errorMessageFromConfig.maxFileSize ?? + `File size too large. The limit is ${convertKbBytesToMB(propertyFields.maxFileSize)} MB.`, + (files) => !files?.some((file) => convertBytesToKB(file.size) > propertyFields.maxFileSize) + ); + } + + function withFileFormat(yupSchema) { + return yupSchema.test( + 'isSupportedFormat', + errorMessage.accept ?? + errorMessageFromConfig.accept ?? + `Unsupported file format. The acceptable formats are ${propertyFields.accept}.`, + (files) => + files && files?.length > 0 + ? files.some((file) => { + const fileType = file.name.split('.').pop(); + return propertyFields.accept.includes(fileType.toLowerCase()); + }) + : true + ); + } + + function withBaseSchema() { + const customErrorMsg = errorMessage.type || errorMessageFromConfig.type; + if (customErrorMsg) { + return baseSchema.typeError(customErrorMsg); + } + return baseSchema; + } + + function buildFieldSetSchema(innerFields) { + const fieldSetShape = {}; + innerFields.forEach((fieldSetfield) => { + if (fieldSetfield.fields) { + fieldSetShape[fieldSetfield.name] = object().shape( + buildFieldSetSchema(fieldSetfield.fields) + ); + } else { + fieldSetShape[fieldSetfield.name] = buildYupSchema( + { + ...fieldSetfield, + inputType: fieldSetfield.type, + }, + config + )(); + } + }); + return fieldSetShape; + } + + function buildGroupArraySchema() { + return object().shape( + propertyFields.nthFieldGroup.fields().reduce( + (schema, groupArrayField) => ({ + ...schema, + [groupArrayField.name]: buildYupSchema(groupArrayField, config)(), + }), + {} + ) + ); + } + + const validators = [withBaseSchema]; + + if (inputType === supportedTypes.GROUP_ARRAY) { + // build schema for the items of a group array + validators[0] = () => withBaseSchema().of(buildGroupArraySchema()); + } else if (inputType === supportedTypes.FIELDSET) { + // build schema for field of a fieldset + validators[0] = () => withBaseSchema().shape(buildFieldSetSchema(propertyFields.fields)); + } + + if (propertyFields.required) { + validators.push(withRequired); + } + + // support minimum with 0 value + if (typeof propertyFields.minimum !== 'undefined') { + validators.push(withMin); + } + + // support minLength with 0 value + if (typeof propertyFields.minLength !== 'undefined') { + validators.push(withMinLength); + } + + if (propertyFields.maximum) { + validators.push(withMax); + } + + if (propertyFields.maxLength) { + validators.push(withMaxLength); + } + + if (propertyFields.pattern) { + validators.push(withMatches); + } + + if (propertyFields.maxFileSize) { + validators.push(withMaxFileSize); + } + + if (propertyFields.accept) { + validators.push(withFileFormat); + } + return flow(validators); +} + +// noSortEdges is the second parameter of shape() and ignores the order of the specified field names +// so that the field order does not matter when a field relies on another one via when() +// Docs https://github.com/jquense/yup#objectshapefields-object-nosortedges-arraystring-string-schema +// Explanation https://gitmemory.com/issue/jquense/yup/720/564591045 +export function getNoSortEdges(fields = []) { + return fields.reduce((list, field) => { + if (field.noSortEdges) { + list.push(field.name); + } + return list; + }, []); +} + +function getSchema(fields = [], config) { + const newSchema = {}; + + fields.forEach((field) => { + if (field.schema) { + if (field.name) { + if (field.inputType === supportedTypes.FIELDSET) { + // Fieldset validation schemas depend on the inner schemas of their fields, + // so we need to rebuild it to take into account any of those updates. + const fieldsetSchema = buildYupSchema(field, config)(); + newSchema[field.name] = fieldsetSchema; + } else { + newSchema[field.name] = field.schema; + } + } else { + Object.assign(newSchema, getSchema(field.fields, config)); + } + } + }); + + return newSchema; +} + +export function buildCompleteYupSchema(fields, config) { + return object().shape(getSchema(fields, config), getNoSortEdges(fields)); +}