From dc8414328964501312d116c798693dcb95a3b7f5 Mon Sep 17 00:00:00 2001 From: Sandrina Pereira Date: Wed, 17 May 2023 11:02:40 +0100 Subject: [PATCH] feat: Initial source code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: António Capelo Co-authored-by: João Almeida Co-authored-by: Hanli Theron Co-authored-by: John Brennan Co-authored-by: Dilvane Zanardine Co-authored-by: Blake Johnston Co-authored-by: Zofia Korcz --- src/calculateConditionalProperties.js | 124 + src/calculateCustomValidationProperties.js | 91 + src/createHeadlessForm.js | 345 ++ src/helpers.js | 575 +++ src/index.js | 1 + src/internals/fields.js | 377 ++ src/internals/helpers.js | 56 + src/internals/index.js | 2 + ...ateHeadlessForm.customValidations.test.jsx | 799 ++++ src/tests/createHeadlessForm.test.js | 3343 +++++++++++++++++ src/tests/helpers.custom.js | 223 ++ src/tests/helpers.js | 1983 ++++++++++ src/tests/internals.helpers.test.js | 175 + src/tests/utils.test.jsx | 32 + src/utils.js | 65 + src/yupSchema.js | 302 ++ 16 files changed, 8493 insertions(+) create mode 100644 src/calculateConditionalProperties.js create mode 100644 src/calculateCustomValidationProperties.js create mode 100644 src/createHeadlessForm.js create mode 100644 src/helpers.js create mode 100644 src/index.js create mode 100644 src/internals/fields.js create mode 100644 src/internals/helpers.js create mode 100644 src/internals/index.js create mode 100644 src/tests/createHeadlessForm.customValidations.test.jsx create mode 100644 src/tests/createHeadlessForm.test.js create mode 100644 src/tests/helpers.custom.js create mode 100644 src/tests/helpers.js create mode 100644 src/tests/internals.helpers.test.js create mode 100644 src/tests/utils.test.jsx create mode 100644 src/utils.js create mode 100644 src/yupSchema.js 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..dcb9cec4 --- /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 }, + 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..e37b7e9d --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,575 @@ +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 || after D#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. !8755 for full context + // 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..6fd16914 --- /dev/null +++ b/src/tests/createHeadlessForm.test.js @@ -0,0 +1,3343 @@ +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, + 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: +
    +
  • Kindness
  • +
  • Ownership
  • +
  • Excellence
  • +
  • Transparency
  • +
  • Ambition
  • +
+

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..6ec2cf84 --- /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 } 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)); +}