@rjsf/core
Version:
A simple React component capable of building HTML forms out of a JSON schema.
801 lines (800 loc) • 44.3 kB
JavaScript
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)] }));
}
}