import { FocusEvent, useCallback, useRef, useState } from 'react'; import { ADDITIONAL_PROPERTY_FLAG, ANY_OF_KEY, getTemplate, getUiOptions, isFormDataAvailable, orderProperties, shouldRenderOptionalField, toFieldPathId, useDeepCompareMemo, ErrorSchema, FieldPathId, FieldPathList, FieldProps, FormContextType, GenericObjectType, ONE_OF_KEY, PROPERTIES_KEY, REF_KEY, Registry, RJSFSchema, StrictRJSFSchema, TranslatableString, } from '@rjsf/utils'; import Markdown from 'markdown-to-jsx'; import get from 'lodash/get'; import has from 'lodash/has'; import isObject from 'lodash/isObject'; import set from 'lodash/set'; import { ADDITIONAL_PROPERTY_KEY_REMOVE } from '../constants'; /** Returns a flag indicating whether the `name` field is required in the object schema * * @param schema - The schema to check * @param name - The name of the field to check for required-ness * @returns - True if the field `name` is required, false otherwise */ function isRequired(schema: S, name: string) { return Array.isArray(schema.required) && schema.required.indexOf(name) !== -1; } /** Returns a default value to be used for a new additional schema property of the given `type` * * @param translateString - The string translation function from the registry * @param type - The type of the new additional schema property */ function getDefaultValue( translateString: Registry['translateString'], type?: RJSFSchema['type'], ) { switch (type) { case 'array': return []; case 'boolean': return false; case 'null': return null; case 'number': return 0; case 'object': return {}; case 'string': default: // We don't have a datatype for some reason (perhaps additionalProperties was true) return translateString(TranslatableString.NewStringDefault); } } /** Props for the `ObjectFieldProperty` component */ interface ObjectFieldPropertyProps< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any, > extends Omit, 'name'> { /** The name of the property within the parent object */ propertyName: string; /** Flag indicating whether this property was added by the additionalProperties UI */ addedByAdditionalProperties: boolean; /** Callback that handles the rename of an additionalProperties-based property key */ handleKeyRename: (oldKey: string, newKey: string) => void; /** Callback that handles the removal of an additionalProperties-based property with key */ handleRemoveProperty: (keyName: string) => void; } /** The `ObjectFieldProperty` component is used to render the `SchemaField` for a child property of an object */ function ObjectFieldProperty( props: ObjectFieldPropertyProps, ) { const { fieldPathId, schema, registry, uiSchema, errorSchema, formData, onChange, onBlur, onFocus, disabled, readonly, required, hideError, propertyName, handleKeyRename, handleRemoveProperty, addedByAdditionalProperties, } = props; const [wasPropertyKeyModified, setWasPropertyKeyModified] = useState(false); const { globalFormOptions, fields } = registry; const { SchemaField } = fields; const innerFieldIdPathId = useDeepCompareMemo( toFieldPathId(propertyName, globalFormOptions, fieldPathId.path), ); /** Returns the `onPropertyChange` handler for the `name` field. Handles the special case where a user is attempting * to clear the data for a field added as an additional property. Calls the `onChange()` handler with the updated * formData. * * @param name - The name of the property * @param addedByAdditionalProperties - Flag indicating whether this property is an additional property * @returns - The onPropertyChange callback for the `name` property */ const onPropertyChange = useCallback( (value: T | undefined, path: FieldPathList, newErrorSchema?: ErrorSchema, id?: string) => { if (value === undefined && addedByAdditionalProperties) { // Don't set value = undefined for fields added by additionalProperties. Doing so removes them from the // formData, which causes them to completely disappear (including the input field for the property name). Unlike // fields which are "mandated" by the schema, these fields can be set to undefined by clicking a "delete field" // button, so set empty values to the empty string. value = '' as unknown as T; } onChange(value, path, newErrorSchema, id); }, [onChange, addedByAdditionalProperties], ); /** The key change event handler; Called when the key associated with a field is changed for an additionalProperty. * simply returns a function that call the `handleKeyChange()` event with the value */ const onKeyRename = useCallback( (value: string) => { if (propertyName !== value) { setWasPropertyKeyModified(true); } handleKeyRename(propertyName, value); }, [propertyName, handleKeyRename], ); /** Returns a callback the handle the blur event, getting the value from the target and passing that along to the * `handleKeyChange` function */ const onKeyRenameBlur = useCallback( (event: FocusEvent) => { const { target: { value }, } = event; onKeyRename(value); }, [onKeyRename], ); /** The property drop/removal event handler; Called when a field is removed in an additionalProperty context */ const onRemoveProperty = useCallback(() => { handleRemoveProperty(propertyName); }, [propertyName, handleRemoveProperty]); return ( ); } /** The `ObjectField` component is used to render a field in the schema that is of type `object`. It tracks whether an * additional property key was modified and what it was modified to * * @param props - The `FieldProps` for this template */ export default function ObjectField( props: FieldProps, ) { const { schema: rawSchema, uiSchema = {}, formData, errorSchema, fieldPathId, name, required = false, disabled, readonly, hideError, onBlur, onFocus, onChange, registry, title, } = props; const { fields, schemaUtils, translateString, globalUiOptions } = registry; const { OptionalDataControlsField } = fields; const formDataRef = useRef(formData); formDataRef.current = formData; const schema: S = schemaUtils.retrieveSchema(rawSchema, formData, true); const uiOptions = getUiOptions(uiSchema, globalUiOptions); const { properties: schemaProperties = {} } = schema; // All the children will use childFieldPathId if present in the props, falling back to the fieldPathId const childFieldPathId = props.childFieldPathId ?? fieldPathId; const lastRenamedProperty = useRef({ previousKey: '', currentKey: undefined as string | undefined }); const templateTitle = uiOptions.title ?? schema.title ?? title ?? name; const description = uiOptions.description ?? schema.description; const renderOptionalField = shouldRenderOptionalField(registry, schema, required, uiSchema); const hasFormData = isFormDataAvailable(formData); let orderedProperties: string[] = []; /** Computes the next available key name from the `preferredKey`, indexing through the already existing keys until one * that is already not assigned is found. * * @param preferredKey - The preferred name of a new key * @param [formData] - The form data in which to check if the desired key already exists * @returns - The name of the next available key from `preferredKey` */ const getAvailableKey = useCallback( (preferredKey: string, formData?: T) => { const { duplicateKeySuffixSeparator = '-' } = getUiOptions(uiSchema, globalUiOptions); let index = 0; let newKey = preferredKey; while (has(formData, newKey)) { newKey = `${preferredKey}${duplicateKeySuffixSeparator}${++index}`; } return newKey; }, [uiSchema, globalUiOptions], ); /** Handles the adding of a new additional property on the given `schema`. Calls the `onChange` callback once the new * default data for that field has been added to the formData. */ const onAddProperty = useCallback(() => { if (!(schema.additionalProperties || schema.patternProperties)) { return; } const { translateString } = registry; const newFormData = { ...formData } as T; const newKey = getAvailableKey('newKey', newFormData); if (schema.patternProperties) { // Cast this to make the `set` work properly set(newFormData as GenericObjectType, newKey, null); } else { let type: RJSFSchema['type'] = undefined; let constValue: RJSFSchema['const'] = undefined; let defaultValue: RJSFSchema['default'] = undefined; if (isObject(schema.additionalProperties)) { type = schema.additionalProperties.type; constValue = schema.additionalProperties.const; defaultValue = schema.additionalProperties.default; let apSchema = schema.additionalProperties; if (REF_KEY in apSchema) { const { schemaUtils } = registry; apSchema = schemaUtils.retrieveSchema({ [REF_KEY]: apSchema[REF_KEY] } as S, formData); type = apSchema.type; constValue = apSchema.const; defaultValue = apSchema.default; } if (!type && (ANY_OF_KEY in apSchema || ONE_OF_KEY in apSchema)) { type = 'object'; } } const newValue = constValue ?? defaultValue ?? getDefaultValue(translateString, type); // Cast this to make the `set` work properly set(newFormData as GenericObjectType, newKey, newValue); } if (lastRenamedProperty.current.previousKey === newKey) { lastRenamedProperty.current.currentKey = newKey; lastRenamedProperty.current.previousKey = getAvailableKey(newKey, newFormData); } onChange(newFormData, childFieldPathId.path); }, [formData, onChange, registry, childFieldPathId, getAvailableKey, schema]); /** Returns a callback function that deals with the rename of a key for an additional property for a schema. That * callback will attempt to rename the key and move the existing data to that key, calling `onChange` when it does. * * @param oldKey - The old key for the field * @param newKey - The new key for the field * @returns - The key change callback function */ const handleKeyRename = useCallback( (oldKey: string, newKey: string) => { if (oldKey !== newKey) { const currentFormData = formDataRef.current; const actualNewKey = getAvailableKey(newKey, currentFormData); const newFormData: GenericObjectType = { ...(currentFormData as GenericObjectType), }; const newKeys: GenericObjectType = { [oldKey]: actualNewKey }; const keyValues = Object.keys(newFormData).map((key) => { const newKey = newKeys[key] || key; return { [newKey]: newFormData[key] }; }); const renamedObj = Object.assign({}, ...keyValues); formDataRef.current = renamedObj as T; if (oldKey !== lastRenamedProperty.current.currentKey) { lastRenamedProperty.current.previousKey = oldKey; } lastRenamedProperty.current.currentKey = actualNewKey; onChange(renamedObj, childFieldPathId.path); } }, [onChange, childFieldPathId, getAvailableKey], ); /** Handles the remove click which calls the `onChange` callback with the special ADDITIONAL_PROPERTY_FIELD_REMOVE * value for the path plus the key to be removed */ const handleRemoveProperty = useCallback( (key: string) => { onChange(ADDITIONAL_PROPERTY_KEY_REMOVE as T, [...childFieldPathId.path, key]); }, [onChange, childFieldPathId], ); /** Returns the stable React key for a property. For the most recently renamed * additional property, returns the previous key so that React reuses the * existing component instance instead of unmounting/remounting it. This * preserves DOM focus naturally without manual focus management. */ const getStableKey = useCallback((property: string) => { if (lastRenamedProperty.current.currentKey === property) { return lastRenamedProperty.current.previousKey; } return property; }, []); if (!renderOptionalField || hasFormData) { try { const properties = Object.keys(schemaProperties); orderedProperties = orderProperties(properties, uiOptions.order); } catch (err) { return (

{translateString(TranslatableString.InvalidObjectField, [name || 'root', (err as Error).message])}

{JSON.stringify(schema)}
); } } const Template = getTemplate<'ObjectFieldTemplate', T, S, F>('ObjectFieldTemplate', registry, uiOptions); const optionalDataControl = renderOptionalField ? ( ) : undefined; const templateProps = { // getDisplayLabel() always returns false for object types, so just check the `uiOptions.label` title: uiOptions.label === false ? '' : templateTitle, description: uiOptions.label === false ? undefined : description, properties: orderedProperties.map((name) => { const addedByAdditionalProperties = has(schema, [PROPERTIES_KEY, name, ADDITIONAL_PROPERTY_FLAG]); const fieldUiSchema = addedByAdditionalProperties ? uiSchema.additionalProperties : uiSchema[name]; const hidden = getUiOptions(fieldUiSchema).widget === 'hidden'; const content = ( key={getStableKey(name)} propertyName={name} required={isRequired(schema, name)} schema={get(schema, [PROPERTIES_KEY, name], {}) as S} uiSchema={fieldUiSchema} errorSchema={get(errorSchema, [name])} fieldPathId={childFieldPathId} formData={get(formData, [name])} handleKeyRename={handleKeyRename} handleRemoveProperty={handleRemoveProperty} addedByAdditionalProperties={addedByAdditionalProperties} onChange={onChange} onBlur={onBlur} onFocus={onFocus} registry={registry} disabled={disabled} readonly={readonly} hideError={hideError} /> ); return { content, name, readonly, disabled, required, hidden, }; }), readonly, disabled, required, fieldPathId, uiSchema, errorSchema, schema, formData, registry, optionalDataControl, className: renderOptionalField ? 'rjsf-optional-object-field' : undefined, }; return