UNPKG

@rjsf/core

Version:

A simple React component capable of building HTML forms out of a JSON schema.

801 lines (800 loc) 44.3 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { Component, createRef } from 'react'; import { createSchemaUtils, deepEquals, ErrorSchemaBuilder, expandUiSchemaDefinitions, getChangedFields, getTemplate, getUiOptions, isObject, mergeObjects, shouldRender, SUBMIT_BTN_OPTIONS_KEY, toErrorList, toFieldPathId, UI_DEFINITIONS_KEY, UI_GLOBAL_OPTIONS_KEY, UI_OPTIONS_KEY, validationDataMerge, DEFAULT_ID_SEPARATOR, DEFAULT_ID_PREFIX, ERRORS_KEY, ID_KEY, getUsedFormData, getFieldNames, } from '@rjsf/utils'; import _cloneDeep from 'lodash-es/cloneDeep.js'; import _get from 'lodash-es/get.js'; import _isEmpty from 'lodash-es/isEmpty.js'; import _pick from 'lodash-es/pick.js'; import _set from 'lodash-es/set.js'; import _toPath from 'lodash-es/toPath.js'; import _unset from 'lodash-es/unset.js'; import getDefaultRegistry from '../getDefaultRegistry.js'; import { ADDITIONAL_PROPERTY_KEY_REMOVE, IS_RESET } from './constants.js'; /** Converts the full `FormState` into the `IChangeEvent` version by picking out the public values * * @param state - The state of the form * @param status - The status provided by the onSubmit * @returns - The `IChangeEvent` for the state */ function toIChangeEvent(state, status) { return { ..._pick(state, ['schema', 'uiSchema', 'fieldPathId', 'schemaUtils', 'formData', 'edit', 'errors', 'errorSchema']), ...(status !== undefined && { status }), }; } /** The `Form` component renders the outer form and all the fields defined in the `schema` */ export default class Form extends Component { /** The ref used to hold the `form` element, this needs to be `any` because `tagName` or `_internalFormWrapper` can * provide any possible type here */ formElement; /** The list of pending changes */ pendingChanges = []; /** Flag to track when we're processing a user-initiated field change. * This prevents componentDidUpdate from reverting oneOf/anyOf option switches. */ _isProcessingUserChange = false; /** Constructs the `Form` from the `props`. Will setup the initial state from the props. It will also call the * `onChange` handler if the initially provided `formData` is modified to add missing default values as part of the * state construction. * * @param props - The initial props for the `Form` */ constructor(props) { super(props); if (!props.validator) { throw new Error('A validator is required for Form functionality to work'); } const { formData: propsFormData, initialFormData, onChange } = props; const formData = propsFormData ?? initialFormData; this.state = this.getStateFromProps(props, formData, undefined, undefined, undefined, true); if (onChange && !deepEquals(this.state.formData, formData)) { onChange(toIChangeEvent(this.state)); } this.formElement = createRef(); } /** * `getSnapshotBeforeUpdate` is a React lifecycle method that is invoked right before the most recently rendered * output is committed to the DOM. It enables your component to capture current values (e.g., scroll position) before * they are potentially changed. * * In this case, it checks if the props have changed since the last render. If they have, it computes the next state * of the component using `getStateFromProps` method and returns it along with a `shouldUpdate` flag set to `true` IF * the `nextState` and `prevState` are different, otherwise `false`. This ensures that we have the most up-to-date * state ready to be applied in `componentDidUpdate`. * * If `formData` hasn't changed, it simply returns an object with `shouldUpdate` set to `false`, indicating that a * state update is not necessary. * * @param prevProps - The previous set of props before the update. * @param prevState - The previous state before the update. * @returns Either an object containing the next state and a flag indicating that an update should occur, or an object * with a flag indicating that an update is not necessary. */ getSnapshotBeforeUpdate(prevProps, prevState) { if (!deepEquals(this.props, prevProps)) { // Compare the previous props formData against the current props formData const formDataChangedFields = getChangedFields(this.props.formData, prevProps.formData); // Compare the current props formData against the current state's formData to determine if the new props were the // result of the onChange from the existing state formData const stateDataChangedFields = getChangedFields(this.props.formData, this.state.formData); const isSchemaChanged = !deepEquals(prevProps.schema, this.props.schema); // When formData is not an object, getChangedFields returns an empty array. // In this case, deepEquals is most needed to check again. const isFormDataChanged = formDataChangedFields.length > 0 || !deepEquals(prevProps.formData, this.props.formData); const isStateDataChanged = stateDataChangedFields.length > 0 || !deepEquals(this.state.formData, this.props.formData); const nextState = this.getStateFromProps(this.props, this.props.formData, // If the `schema` has changed, we need to update the retrieved schema. // Or if the `formData` changes, for example in the case of a schema with dependencies that need to // match one of the subSchemas, the retrieved schema must be updated. isSchemaChanged || isFormDataChanged ? undefined : this.state.retrievedSchema, isSchemaChanged, formDataChangedFields, // Skip live validation for this request if no form data has changed from the last state !isStateDataChanged); const shouldUpdate = !deepEquals(nextState, prevState); return { nextState, shouldUpdate }; } return { shouldUpdate: false }; } /** * `componentDidUpdate` is a React lifecycle method that is invoked immediately after updating occurs. This method is * not called for the initial render. * * Here, it checks if an update is necessary based on the `shouldUpdate` flag received from `getSnapshotBeforeUpdate`. * If an update is required, it applies the next state and, if needed, triggers the `onChange` handler to inform about * changes. * * @param _ - The previous set of props. * @param prevState - The previous state of the component before the update. * @param snapshot - The value returned from `getSnapshotBeforeUpdate`. */ componentDidUpdate(_, prevState, snapshot) { if (snapshot.shouldUpdate) { const { nextState } = snapshot; // Prevent oneOf/anyOf option switches from reverting when getStateFromProps // re-evaluates and produces stale formData. const nextStateDiffersFromProps = !deepEquals(nextState.formData, this.props.formData); const wasProcessingUserChange = this._isProcessingUserChange; this._isProcessingUserChange = false; if (wasProcessingUserChange && nextStateDiffersFromProps) { // Skip - the user's option switch is already applied via processPendingChange return; } if (nextStateDiffersFromProps && !deepEquals(nextState.formData, prevState.formData) && this.props.onChange) { this.props.onChange(toIChangeEvent(nextState)); } this.setState(nextState); } } /** Extracts the updated state from the given `props` and `inputFormData`. As part of this process, the * `inputFormData` is first processed to add any missing required defaults. After that, the data is run through the * validation process IF required by the `props`. * * @param props - The props passed to the `Form` * @param inputFormData - The new or current data for the `Form` * @param retrievedSchema - An expanded schema, if not provided, it will be retrieved from the `schema` and `formData`. * @param isSchemaChanged - A flag indicating whether the schema has changed. * @param formDataChangedFields - The changed fields of `formData` * @param skipLiveValidate - Optional flag, if true, means that we are not running live validation * @returns - The new state for the `Form` */ getStateFromProps(props, inputFormData, retrievedSchema, isSchemaChanged = false, formDataChangedFields = [], skipLiveValidate = false) { const state = this.state || {}; const schema = 'schema' in props ? props.schema : this.props.schema; const validator = 'validator' in props ? props.validator : this.props.validator; const uiSchema = ('uiSchema' in props ? props.uiSchema : this.props.uiSchema) || {}; const isUncontrolled = props.formData === undefined && this.props.formData === undefined; const edit = typeof inputFormData !== 'undefined'; const liveValidate = 'liveValidate' in props ? props.liveValidate : this.props.liveValidate; const mustValidate = edit && !props.noValidate && liveValidate; const experimental_defaultFormStateBehavior = 'experimental_defaultFormStateBehavior' in props ? props.experimental_defaultFormStateBehavior : this.props.experimental_defaultFormStateBehavior; const experimental_customMergeAllOf = 'experimental_customMergeAllOf' in props ? props.experimental_customMergeAllOf : this.props.experimental_customMergeAllOf; let schemaUtils = state.schemaUtils; if (!schemaUtils || schemaUtils.doesSchemaUtilsDiffer(validator, schema, experimental_defaultFormStateBehavior, experimental_customMergeAllOf)) { schemaUtils = createSchemaUtils(validator, schema, experimental_defaultFormStateBehavior, experimental_customMergeAllOf); } const rootSchema = schemaUtils.getRootSchema(); // Compute the formData for getDefaultFormState() function based on the inputFormData, isUncontrolled and state let defaultsFormData = inputFormData; if (inputFormData === IS_RESET) { defaultsFormData = undefined; } else if (inputFormData === undefined && isUncontrolled) { defaultsFormData = state.formData; } const formData = schemaUtils.getDefaultFormState(rootSchema, defaultsFormData, false, state.initialDefaultsGenerated); const _retrievedSchema = this.updateRetrievedSchema(retrievedSchema ?? schemaUtils.retrieveSchema(rootSchema, formData)); const getCurrentErrors = () => { // If the `props.noValidate` option is set or the schema has changed, we reset the error state. if (props.noValidate || isSchemaChanged) { return { errors: [], errorSchema: {} }; } else if (!props.liveValidate) { return { errors: state.schemaValidationErrors || [], errorSchema: state.schemaValidationErrorSchema || {}, }; } return { errors: state.errors || [], errorSchema: state.errorSchema || {}, }; }; let errors; let errorSchema; let schemaValidationErrors = state.schemaValidationErrors; let schemaValidationErrorSchema = state.schemaValidationErrorSchema; // If we are skipping live validate, it means that the state has already been updated with live validation errors if (mustValidate && !skipLiveValidate) { const liveValidation = this.liveValidate(rootSchema, schemaUtils, state.errorSchema, formData, undefined, state.customErrors, retrievedSchema, // If retrievedSchema is undefined which means the schema or formData has changed, we do not merge state. // Else in the case where it hasn't changed, retrievedSchema !== undefined); errors = liveValidation.errors; errorSchema = liveValidation.errorSchema; schemaValidationErrors = liveValidation.schemaValidationErrors; schemaValidationErrorSchema = liveValidation.schemaValidationErrorSchema; } else { const currentErrors = getCurrentErrors(); errors = currentErrors.errors; errorSchema = currentErrors.errorSchema; // We only update the error schema for changed fields if mustValidate is false if (formDataChangedFields.length > 0 && !mustValidate) { const newErrorSchema = formDataChangedFields.reduce((acc, key) => { acc[key] = undefined; return acc; }, {}); errorSchema = schemaValidationErrorSchema = mergeObjects(currentErrors.errorSchema, newErrorSchema, 'preventDuplicates'); } const mergedErrors = this.mergeErrors({ errorSchema, errors }, props.extraErrors, state.customErrors); errors = mergedErrors.errors; errorSchema = mergedErrors.errorSchema; } // Only store a new registry when the props cause a different one to be created const newRegistry = this.getRegistry(props, rootSchema, schemaUtils); const registry = deepEquals(state.registry, newRegistry) ? state.registry : newRegistry; // Pre-expand ui:definitions into the uiSchema structure (must happen after registry is created) const expandedUiSchema = registry.uiSchemaDefinitions ? expandUiSchemaDefinitions(rootSchema, uiSchema, registry) : uiSchema; // Only compute a new `fieldPathId` when the `idPrefix` is different than the existing fieldPathId's ID_KEY const fieldPathId = state.fieldPathId && state.fieldPathId?.[ID_KEY] === registry.globalFormOptions.idPrefix ? state.fieldPathId : toFieldPathId('', registry.globalFormOptions); const nextState = { schemaUtils, schema: rootSchema, uiSchema: expandedUiSchema, fieldPathId, formData, edit, errors, errorSchema, schemaValidationErrors, schemaValidationErrorSchema, retrievedSchema: _retrievedSchema, initialDefaultsGenerated: true, registry, }; return nextState; } /** React lifecycle method that is used to determine whether component should be updated. * * @param nextProps - The next version of the props * @param nextState - The next version of the state * @returns - True if the component should be updated, false otherwise */ shouldComponentUpdate(nextProps, nextState) { const { experimental_componentUpdateStrategy = 'customDeep' } = this.props; return shouldRender(this, nextProps, nextState, experimental_componentUpdateStrategy); } /** Validates the `formData` against the `schema` using the `altSchemaUtils` (if provided otherwise it uses the * `schemaUtils` in the state), returning the results. * * @param formData - The new form data to validate * @param schema - The schema used to validate against * @param [altSchemaUtils] - The alternate schemaUtils to use for validation * @param [retrievedSchema] - An optionally retrieved schema for per */ validate(formData, schema = this.state.schema, altSchemaUtils, retrievedSchema) { const schemaUtils = altSchemaUtils ? altSchemaUtils : this.state.schemaUtils; const { customValidate, transformErrors, uiSchema } = this.props; const resolvedSchema = retrievedSchema ?? schemaUtils.retrieveSchema(schema, formData); return schemaUtils .getValidator() .validateFormData(formData, resolvedSchema, customValidate, transformErrors, uiSchema); } /** Renders any errors contained in the `state` in using the `ErrorList`, if not disabled by `showErrorList`. */ renderErrors(registry) { const { errors, errorSchema, schema, uiSchema } = this.state; const options = getUiOptions(uiSchema); const ErrorListTemplate = getTemplate('ErrorListTemplate', registry, options); if (errors && errors.length) { return (_jsx(ErrorListTemplate, { errors: errors, errorSchema: errorSchema || {}, schema: schema, uiSchema: uiSchema, registry: registry })); } return null; } /** Merges any `extraErrors` or `customErrors` into the given `schemaValidation` object, returning the result * * @param schemaValidation - The `ValidationData` object into which additional errors are merged * @param [extraErrors] - The extra errors from the props * @param [customErrors] - The customErrors from custom components * @return - The `extraErrors` and `customErrors` merged into the `schemaValidation` * @private */ mergeErrors(schemaValidation, extraErrors, customErrors) { let errorSchema = schemaValidation.errorSchema; let errors = schemaValidation.errors; if (extraErrors) { const merged = validationDataMerge(schemaValidation, extraErrors); errorSchema = merged.errorSchema; errors = merged.errors; } if (customErrors) { const merged = validationDataMerge(schemaValidation, customErrors.ErrorSchema, true); errorSchema = merged.errorSchema; errors = merged.errors; } return { errors, errorSchema }; } /** Performs live validation and then updates and returns the errors and error schemas by potentially merging in * `extraErrors` and `customErrors`. * * @param rootSchema - The `rootSchema` from the state * @param schemaUtils - The `SchemaUtilsType` from the state * @param originalErrorSchema - The original `ErrorSchema` from the state * @param [formData] - The new form data to validate * @param [extraErrors] - The extra errors from the props * @param [customErrors] - The customErrors from custom components * @param [retrievedSchema] - An expanded schema, if not provided, it will be retrieved from the `schema` and `formData` * @param [mergeIntoOriginalErrorSchema=false] - Optional flag indicating whether we merge into original schema * @returns - An object containing `errorSchema`, `errors`, `schemaValidationErrors` and `schemaValidationErrorSchema` * @private */ liveValidate(rootSchema, schemaUtils, originalErrorSchema, formData, extraErrors, customErrors, retrievedSchema, mergeIntoOriginalErrorSchema = false) { const schemaValidation = this.validate(formData, rootSchema, schemaUtils, retrievedSchema); const errors = schemaValidation.errors; let errorSchema = schemaValidation.errorSchema; // We merge 'originalErrorSchema' with 'schemaValidation.errorSchema.'; This done to display the raised field error. if (mergeIntoOriginalErrorSchema) { errorSchema = mergeObjects(originalErrorSchema, schemaValidation.errorSchema, 'preventDuplicates'); } const schemaValidationErrors = errors; const schemaValidationErrorSchema = errorSchema; const mergedErrors = this.mergeErrors({ errorSchema, errors }, extraErrors, customErrors); return { ...mergedErrors, schemaValidationErrors, schemaValidationErrorSchema }; } /** Returns the `formData` with only the elements specified in the `fields` list * * @param formData - The data for the `Form` * @param fields - The fields to keep while filtering * @deprecated - To be removed as an exported `Form` function in a future release; there isn't a planned replacement */ getUsedFormData = (formData, fields) => { return getUsedFormData(formData, fields); }; /** Returns the list of field names from inspecting the `pathSchema` as well as using the `formData` * * @param pathSchema - The `PathSchema` object for the form * @param [formData] - The form data to use while checking for empty objects/arrays * @deprecated - To be removed as an exported `Form` function in a future release; there isn't a planned replacement */ getFieldNames = (pathSchema, formData) => { return getFieldNames(pathSchema, formData); }; /** Returns the `formData` after filtering to remove any extra data not in a form field * * @param formData - The data for the `Form` * @returns The `formData` after omitting extra data * @deprecated - To be removed as an exported `Form` function in a future release, use `SchemaUtils.omitExtraData` * instead. */ omitExtraData = (formData) => { const { schema, schemaUtils } = this.state; return schemaUtils.omitExtraData(schema, formData); }; /** Allows a user to set a value for the provided `fieldPath`, which must be either a dotted path to the field OR a * `FieldPathList`. To set the root element, used either `''` or `[]` for the path. Passing undefined will clear the * value in the field. * * @param fieldPath - Either a dotted path to the field or the `FieldPathList` to the field * @param [newValue] - The new value for the field */ setFieldValue = (fieldPath, newValue) => { const { registry } = this.state; const path = Array.isArray(fieldPath) ? fieldPath : fieldPath.split('.'); const fieldPathId = toFieldPathId('', registry.globalFormOptions, path); this.onChange(newValue, path, undefined, fieldPathId[ID_KEY]); }; /** Pushes the given change information into the `pendingChanges` array and then calls `processPendingChanges()` if * the array only contains a single pending change. * * @param newValue - The new form data from a change to a field * @param path - The path to the change into which to set the formData * @param [newErrorSchema] - The new `ErrorSchema` based on the field change * @param [id] - The id of the field that caused the change */ onChange = (newValue, path, newErrorSchema, id) => { this.pendingChanges.push({ newValue, path, newErrorSchema, id }); if (this.pendingChanges.length === 1) { this.processPendingChange(); } }; /** Function to handle changes made to a field in the `Form`. This handler gets the first change from the * `pendingChanges` list, containing the `newValue` for the `formData` and the `path` at which the `newValue` is to be * updated, along with a new, optional `ErrorSchema` for that same `path` and potentially the `id` of the field being * changed. It will first update the `formData` with any missing default fields and then, if `omitExtraData` and * `liveOmit` are turned on, the `formData` will be filtered to remove any extra data not in a form field. Then, the * resulting `formData` will be validated if required. The state will be updated with the new updated (potentially * filtered) `formData`, any errors that resulted from validation. Finally the `onChange` callback will be called, if * specified, with the updated state and the `processPendingChange()` function is called again. */ processPendingChange() { if (this.pendingChanges.length === 0) { return; } // Mark that we're processing a user-initiated change. // This prevents componentDidUpdate from reverting oneOf/anyOf option switches. this._isProcessingUserChange = true; const { newValue, path, id } = this.pendingChanges[0]; const { newErrorSchema } = this.pendingChanges[0]; const { extraErrors, omitExtraData, liveOmit, noValidate, liveValidate, onChange } = this.props; const { formData: oldFormData, schemaUtils, schema, fieldPathId, schemaValidationErrorSchema, errors } = this.state; let { customErrors, errorSchema: originalErrorSchema } = this.state; const rootPathId = fieldPathId.path[0] || ''; const isRootPath = !path || path.length === 0 || (path.length === 1 && path[0] === rootPathId); let retrievedSchema = this.state.retrievedSchema; let formData = isRootPath ? newValue : _cloneDeep(oldFormData); // When switching from null to an object option in oneOf, MultiSchemaField sends // an object with property names but undefined values (e.g., {types: undefined, content: undefined}). // In this case, pass undefined to getStateFromProps to trigger fresh default computation. // Only do this when the previous formData was null/undefined (switching FROM null). const hasOnlyUndefinedValues = isObject(formData) && Object.keys(formData).length > 0 && Object.values(formData).every((v) => v === undefined); const wasPreviouslyNull = oldFormData === null || oldFormData === undefined; const inputForDefaults = hasOnlyUndefinedValues && wasPreviouslyNull ? undefined : formData; if (isObject(formData) || Array.isArray(formData)) { if (newValue === ADDITIONAL_PROPERTY_KEY_REMOVE) { // For additional properties, we were given the special remove this key value, so unset it _unset(formData, path); } else if (!isRootPath) { // If the newValue is not on the root path, then set it into the form data _set(formData, path, newValue); } // Pass true to skip live validation in `getStateFromProps()` since we will do it a bit later const newState = this.getStateFromProps(this.props, inputForDefaults, undefined, undefined, undefined, true); formData = newState.formData; retrievedSchema = newState.retrievedSchema; } const mustValidate = !noValidate && (liveValidate === true || liveValidate === 'onChange'); let state = { formData, schema }; let newFormData = formData; if (omitExtraData === true && (liveOmit === true || liveOmit === 'onChange')) { newFormData = this.omitExtraData(formData); state = { formData: newFormData, }; } if (newErrorSchema) { // First check to see if there is an existing validation error on this path... // @ts-expect-error TS2590, because getting from the error schema is confusing TS const oldValidationError = !isRootPath ? _get(schemaValidationErrorSchema, path) : schemaValidationErrorSchema; // If there is an old validation error for this path, assume we are updating it directly if (!_isEmpty(oldValidationError)) { // Update the originalErrorSchema "in place" or replace it if it is the root if (!isRootPath) { _set(originalErrorSchema, path, newErrorSchema); } else { originalErrorSchema = newErrorSchema; } } else { if (!customErrors) { customErrors = new ErrorSchemaBuilder(); } if (isRootPath) { const errors = _get(newErrorSchema, ERRORS_KEY); if (errors) { // only set errors when there are some customErrors.setErrors(errors); } } else { _set(customErrors.ErrorSchema, path, newErrorSchema); } } } else if (customErrors && _get(customErrors.ErrorSchema, [...path, ERRORS_KEY])) { // If we have custom errors and the path has an error, then we need to clear it customErrors.clearErrors(path); } // If there are pending changes in the queue, skip live validation since it will happen with the last change if (mustValidate && this.pendingChanges.length === 1) { const liveValidation = this.liveValidate(schema, schemaUtils, originalErrorSchema, newFormData, extraErrors, customErrors, retrievedSchema); state = { formData: newFormData, ...liveValidation, customErrors }; } else if (!noValidate && newErrorSchema) { // Merging 'newErrorSchema' into 'errorSchema' to display the custom raised errors. const mergedErrors = this.mergeErrors({ errorSchema: originalErrorSchema, errors }, extraErrors, customErrors); state = { formData: newFormData, ...mergedErrors, customErrors, }; } this.setState(state, () => { if (onChange) { onChange(toIChangeEvent({ ...this.state, ...state }), id); } // Now remove the change we just completed and call this again this.pendingChanges.shift(); this.processPendingChange(); }); } /** * If the retrievedSchema has changed the new retrievedSchema is returned. * Otherwise, the old retrievedSchema is returned to persist reference. * - This ensures that AJV retrieves the schema from the cache when it has not changed, * avoiding the performance cost of recompiling the schema. * * @param retrievedSchema The new retrieved schema. * @returns The new retrieved schema if it has changed, else the old retrieved schema. */ updateRetrievedSchema(retrievedSchema) { const isTheSame = deepEquals(retrievedSchema, this.state?.retrievedSchema); return isTheSame ? this.state.retrievedSchema : retrievedSchema; } /** * Callback function to handle reset form data. * - Reset all fields with default values. * - Reset validations and errors * */ reset = () => { // Cast the IS_RESET symbol to T to avoid type issues, we use this symbol to detect reset mode const { formData: propsFormData, initialFormData = IS_RESET, onChange } = this.props; const newState = this.getStateFromProps(this.props, propsFormData ?? initialFormData, undefined, undefined, undefined, true); const newFormData = newState.formData; const state = { formData: newFormData, errorSchema: {}, errors: [], schemaValidationErrors: [], schemaValidationErrorSchema: {}, initialDefaultsGenerated: false, customErrors: undefined, }; this.setState(state, () => onChange && onChange(toIChangeEvent({ ...this.state, ...state }))); }; /** Callback function to handle when a field on the form is blurred. Calls the `onBlur` callback for the `Form` if it * was provided. Also runs any live validation and/or live omit operations if the flags indicate they should happen * during `onBlur`. * * @param id - The unique `id` of the field that was blurred * @param data - The data associated with the field that was blurred */ onBlur = (id, data) => { const { onBlur, omitExtraData, liveOmit, liveValidate } = this.props; if (onBlur) { onBlur(id, data); } if ((omitExtraData === true && liveOmit === 'onBlur') || liveValidate === 'onBlur') { const { onChange, extraErrors } = this.props; const { formData } = this.state; let newFormData = formData; let state = { formData: newFormData }; if (omitExtraData === true && liveOmit === 'onBlur') { newFormData = this.omitExtraData(formData); state = { formData: newFormData }; } if (liveValidate === 'onBlur') { const { schema, schemaUtils, errorSchema, customErrors, retrievedSchema } = this.state; const liveValidation = this.liveValidate(schema, schemaUtils, errorSchema, newFormData, extraErrors, customErrors, retrievedSchema); state = { formData: newFormData, ...liveValidation, customErrors }; } const hasChanges = Object.keys(state) // Filter out `schemaValidationErrors` and `schemaValidationErrorSchema` since they aren't IChangeEvent props .filter((key) => !key.startsWith('schemaValidation')) .some((key) => { const oldData = _get(this.state, key); const newData = _get(state, key); return !deepEquals(oldData, newData); }); this.setState(state, () => { if (onChange && hasChanges) { onChange(toIChangeEvent({ ...this.state, ...state }), id); } }); } }; /** Callback function to handle when a field on the form is focused. Calls the `onFocus` callback for the `Form` if it * was provided. * * @param id - The unique `id` of the field that was focused * @param data - The data associated with the field that was focused */ onFocus = (id, data) => { const { onFocus } = this.props; if (onFocus) { onFocus(id, data); } }; /** Callback function to handle when the form is submitted. First, it prevents the default event behavior. Nothing * happens if the target and currentTarget of the event are not the same. It will omit any extra data in the * `formData` in the state if `omitExtraData` is true. It will validate the resulting `formData`, reporting errors * via the `onError()` callback unless validation is disabled. Finally, it will add in any `extraErrors` and then call * back the `onSubmit` callback if it was provided. * * @param event - The submit HTML form event */ onSubmit = (event) => { event.preventDefault(); if (event.target !== event.currentTarget) { return; } event.persist(); const { omitExtraData, extraErrors, noValidate, onSubmit } = this.props; let { formData: newFormData } = this.state; if (omitExtraData === true) { newFormData = this.omitExtraData(newFormData); } if (noValidate || this.validateFormWithFormData(newFormData)) { // There are no errors generated through schema validation. // Check for user provided errors and update state accordingly. const errorSchema = extraErrors || {}; const errors = extraErrors ? toErrorList(extraErrors) : []; this.setState({ formData: newFormData, errors, errorSchema, schemaValidationErrors: [], schemaValidationErrorSchema: {}, }, () => { if (onSubmit) { onSubmit(toIChangeEvent({ ...this.state, formData: newFormData }, 'submitted'), event); } }); } }; /** Extracts the `GlobalFormOptions` from the given Form `props` * * @param props - The form props to extract the global form options from * @returns - The `GlobalFormOptions` from the props * @private */ getGlobalFormOptions(props) { const { uiSchema = {}, experimental_componentUpdateStrategy, idSeparator = DEFAULT_ID_SEPARATOR, idPrefix = DEFAULT_ID_PREFIX, nameGenerator, useFallbackUiForUnsupportedType = false, } = props; const rootFieldId = uiSchema['ui:rootFieldId']; // Omit any options that are undefined or null return { idPrefix: rootFieldId || idPrefix, idSeparator, useFallbackUiForUnsupportedType, ...(experimental_componentUpdateStrategy !== undefined && { experimental_componentUpdateStrategy }), ...(nameGenerator !== undefined && { nameGenerator }), }; } /** Computed the registry for the form using the given `props`, `schema` and `schemaUtils` */ getRegistry(props, schema, schemaUtils) { const { translateString: customTranslateString, uiSchema = {} } = props; const { fields, templates, widgets, formContext, translateString } = getDefaultRegistry(); return { fields: { ...fields, ...props.fields }, templates: { ...templates, ...props.templates, ButtonTemplates: { ...templates.ButtonTemplates, ...props.templates?.ButtonTemplates, }, }, widgets: { ...widgets, ...props.widgets }, rootSchema: schema, formContext: props.formContext || formContext, schemaUtils, translateString: customTranslateString || translateString, globalUiOptions: uiSchema[UI_GLOBAL_OPTIONS_KEY], globalFormOptions: this.getGlobalFormOptions(props), uiSchemaDefinitions: uiSchema[UI_DEFINITIONS_KEY] ?? {}, }; } /** Provides a function that can be used to programmatically submit the `Form` */ submit = () => { if (this.formElement.current) { const submitCustomEvent = new CustomEvent('submit', { cancelable: true, }); submitCustomEvent.preventDefault(); this.formElement.current.dispatchEvent(submitCustomEvent); this.formElement.current.requestSubmit(); } }; /** Attempts to focus on the field associated with the `error`. Uses the `property` field to compute path of the error * field, then, using the `idPrefix` and `idSeparator` converts that path into an id. Then the input element with that * id is attempted to be found using the `formElement` ref. If it is located, then it is focused. * * @param error - The error on which to focus */ focusOnError(error) { const { idPrefix = 'root', idSeparator = '_' } = this.props; const { property } = error; const path = _toPath(property); if (path[0] === '') { // Most of the time the `.foo` property results in the first element being empty, so replace it with the idPrefix path[0] = idPrefix; } else { // Otherwise insert the idPrefix into the first location using unshift path.unshift(idPrefix); } const elementId = path.join(idSeparator); let field = this.formElement.current.elements[elementId]; if (!field) { // if not an exact match, try finding an input starting with the element id (like radio buttons or checkboxes) field = this.formElement.current.querySelector(`input[id^="${elementId}"`); } if (field && field.length) { // If we got a list with length > 0 field = field[0]; } if (field) { field.focus(); } } /** Validates the form using the given `formData`. For use on form submission or on programmatic validation. * If `onError` is provided, then it will be called with the list of errors. * * @param formData - The form data to validate * @returns - True if the form is valid, false otherwise. */ validateFormWithFormData = (formData) => { const { extraErrors, extraErrorsBlockSubmit, focusOnFirstError, onError } = this.props; const { errors: prevErrors } = this.state; const schemaValidation = this.validate(formData); let errors = schemaValidation.errors; let errorSchema = schemaValidation.errorSchema; const schemaValidationErrors = errors; const schemaValidationErrorSchema = errorSchema; const hasError = errors.length > 0 || (extraErrors && extraErrorsBlockSubmit); if (hasError) { if (extraErrors) { const merged = validationDataMerge(schemaValidation, extraErrors); errorSchema = merged.errorSchema; errors = merged.errors; } if (focusOnFirstError) { if (typeof focusOnFirstError === 'function') { focusOnFirstError(errors[0]); } else { this.focusOnError(errors[0]); } } this.setState({ errors, errorSchema, schemaValidationErrors, schemaValidationErrorSchema, }, () => { if (onError) { onError(errors); } else { console.error('Form validation failed', errors); } }); } else if (prevErrors.length > 0) { this.setState({ errors: [], errorSchema: {}, schemaValidationErrors: [], schemaValidationErrorSchema: {}, }); } return !hasError; }; /** Programmatically validate the form. If `omitExtraData` is true, the `formData` will first be filtered to remove * any extra data not in a form field. If `onError` is provided, then it will be called with the list of errors the * same way as would happen on form submission. * * @returns - True if the form is valid, false otherwise. */ validateForm() { const { omitExtraData } = this.props; let { formData: newFormData } = this.state; if (omitExtraData === true) { newFormData = this.omitExtraData(newFormData); } return this.validateFormWithFormData(newFormData); } /** Renders the `Form` fields inside the <form> | `tagName` or `_internalFormWrapper`, rendering any errors if * needed along with the submit button or any children of the form. */ render() { const { children, id, className = '', tagName, name, method, target, action, autoComplete, enctype, acceptCharset, noHtml5Validate = false, disabled, readonly, showErrorList = 'top', _internalFormWrapper, } = this.props; const { schema, uiSchema, formData, errorSchema, fieldPathId, registry } = this.state; const { SchemaField: _SchemaField } = registry.fields; const { SubmitButton } = registry.templates.ButtonTemplates; // The `semantic-ui` and `material-ui` themes have `_internalFormWrapper`s that take an `as` prop that is the // PropTypes.elementType to use for the inner tag, so we'll need to pass `tagName` along if it is provided. // NOTE, the `as` prop is native to `semantic-ui` and is emulated in the `material-ui` theme const as = _internalFormWrapper ? tagName : undefined; const FormTag = _internalFormWrapper || tagName || 'form'; let { [SUBMIT_BTN_OPTIONS_KEY]: submitOptions = {} } = getUiOptions(uiSchema); if (disabled) { submitOptions = { ...submitOptions, props: { ...submitOptions.props, disabled: true } }; } const submitUiSchema = { [UI_OPTIONS_KEY]: { [SUBMIT_BTN_OPTIONS_KEY]: submitOptions } }; return (_jsxs(FormTag, { className: className ? className : 'rjsf', id: id, name: name, method: method, target: target, action: action, autoComplete: autoComplete, encType: enctype, acceptCharset: acceptCharset, noValidate: noHtml5Validate, onSubmit: this.onSubmit, as: as, ref: this.formElement, children: [showErrorList === 'top' && this.renderErrors(registry), _jsx(_SchemaField, { name: '', schema: schema, uiSchema: uiSchema, errorSchema: errorSchema, fieldPathId: fieldPathId, formData: formData, onChange: this.onChange, onBlur: this.onBlur, onFocus: this.onFocus, registry: registry, disabled: disabled, readonly: readonly }), children ? children : _jsx(SubmitButton, { uiSchema: submitUiSchema, registry: registry }), showErrorList === 'bottom' && this.renderErrors(registry)] })); } }