Source

components/jsonSchema/form.js

import React, { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import Ajv from 'ajv'
import JsonSchemaDraft04 from 'ajv/lib/refs/json-schema-draft-04.json'

import { warning } from '../../utils/log'

const FormContext = React.createContext(null)

/**
 * Context for handling a JSONSchema form.
 *
 * @component
 */
const JsonSchemaForm = ({
  schema,
  onChange,
  renderLabel,
  enumSelectThreshold,
  initialValues,
  initialValid,
  children,
}) => {
  const [currentSchema, setCurrentSchema] = useState(null)
  const [ajv, setAjv] = useState(null)
  const [errors, setErrors] = useState({})
  const [values, setValues] = useState(initialValues)
  const [isValid, setIsValid] = useState(initialValid)

  const validate = newValues => {
    if (!ajv) {
      setErrors({})
      return true
    }

    // validate against schema
    const ajvValidate = ajv.getSchema('schema')
    // sometimes the schema loading does not work or is not finished
    // before the first edit, this is to prevent crashes
    if (ajvValidate) {
      const newIsValid = ajvValidate(newValues)
      setIsValid(newIsValid)
      if (newIsValid) {
        setErrors({})
      } else {
        const newErrors = {}
        ajvValidate.errors.forEach(error => {
          const field = error.dataPath.substring(1)
          if (newErrors[field]) {
            newErrors[field].push(error.message)
          } else {
            newErrors[field] = [error.message]
          }
        })
        setErrors(newErrors)
      }
      return newIsValid
    }
    return initialValid
  }

  const changeHandler = ({ name, value }) => {
    const newValues = { ...values, [name]: value }
    setValues(newValues)
    const newIsValid = validate(newValues)
    onChange({ values: newValues, isValid: newIsValid })
  }

  const getErrorsForField = fieldName => {
    return errors[fieldName] || []
  }

  // Set unchanged values when initialValues changed.
  useEffect(() => {
    const newValues = { ...initialValues, ...values }
    setValues(newValues)
    const newIsValid = validate(newValues)
    onChange({ values: newValues, isValid: newIsValid })
  }, [initialValues])

  // Initialize/update AJV instance when schema has changed.
  useEffect(() => {
    const modifiedSchema = () => {
      const objectSchema = { ...schema }
      Object.keys(objectSchema.properties).forEach(property => {
        if (
          objectSchema.properties[property].type === 'media' ||
          objectSchema.properties[property].type === 'json_schema_object'
        ) {
          objectSchema.properties[property].type = 'object'
        }
        if (objectSchema.properties[property].format === 'objectid') {
          delete objectSchema.properties[property]
        } else if (objectSchema.properties[property].nullable) {
          // translate nullable field from OpenAPI specification to
          // possible type null in jsonschema
          objectSchema.properties[property].type = [
            'null',
            objectSchema.properties[property].type,
          ]
          if ('enum' in objectSchema.properties[property]) {
            objectSchema.properties[property].enum.push(null)
          }
        }
      })
      return objectSchema
    }

    // Prevent recreation of validator instance if only schema reference has changed.
    if (currentSchema === JSON.stringify(schema)) return

    if (ajv) {
      warning(
        'JsonSchemaForm: It is not recommended to change the schema of a form during its use!'
      )
    }

    if (!schema) {
      setAjv(null)
      setErrors({})
      setCurrentSchema(null)
      warning('JsonSchemaForm: It is not recommended to use without a schema!')
      return
    }

    const newAjv = new Ajv({
      // Ajv gives a warning that the schema property `$id` is ignored.
      // With draft-06, the property `id` changed to `$id` which seems
      // to give some trouble with ajv even when using draft-04 only.
      // Setting this to auto just prevents those warnings.
      // Validation seems to work fine.
      schemaId: 'auto',
      missingRefs: 'ignore',
      errorDataPath: 'property',
      allErrors: true,
    })
    newAjv.addMetaSchema(JsonSchemaDraft04)
    newAjv.addSchema(modifiedSchema(), 'schema')

    setAjv(newAjv)
    setErrors({})
    setCurrentSchema(JSON.stringify(schema))
  }, [schema])

  return (
    <FormContext.Provider
      value={{
        schema,
        enumSelectThreshold,
        isValid,
        values,
        onChange: changeHandler,
        renderLabel,
        getErrorsForField,
      }}
    >
      {children}
    </FormContext.Provider>
  )
}

JsonSchemaForm.propTypes = {
  /** JSON Schema form description object */
  schema: PropTypes.object.isRequired,
  /** Callback when the values have changed */
  onChange: PropTypes.func,
  /** Threshold to switch between RadioGroup and Select for enum fields */
  enumSelectThreshold: PropTypes.number,
  /** Initial values for the form fields */
  initialValues: PropTypes.object,
  /** Specifies whether the form is initially valid */
  initialValid: PropTypes.bool,
  /** Components rendered within this context */
  children: PropTypes.node.isRequired,
  /** Function to render a given label. */
  renderLabel: PropTypes.func,
}

JsonSchemaForm.defaultProps = {
  enumSelectThreshold: 4,
  initialValues: {},
  initialValid: true,
  onChange: () => {},
  renderLabel: label => label,
}

/**
 * React hook to access the form context within a custom component.
 */
const useForm = () => React.useContext(FormContext)

export default JsonSchemaForm
export { useForm }