From bded35567d66eb9da237f2e97c17bedb4143869c Mon Sep 17 00:00:00 2001 From: Heath Chiavettone Date: Tue, 14 Oct 2025 18:18:10 -0700 Subject: [PATCH 1/5] Chore: Convert ObjectField to a stateless component The work to convert `ObjectField` to a stateless functional component with some optimizations --- .../src/templates/FieldTemplate/index.tsx | 4 + .../templates/ObjectFieldTemplate/index.tsx | 4 +- .../WrapIfAdditionalTemplate/index.tsx | 11 +- .../src/FieldTemplate/FieldTemplate.tsx | 4 + .../ObjectFieldTemplate.tsx | 4 +- .../WrapIfAdditionalTemplate.tsx | 11 +- packages/core/src/components/Form.tsx | 87 ++- .../core/src/components/fields/ArrayField.tsx | 8 +- .../fields/LayoutMultiSchemaField.tsx | 2 + .../components/fields/MultiSchemaField.tsx | 2 +- .../src/components/fields/ObjectField.tsx | 563 ++++++++++-------- .../fields/OptionalDataControlsField.tsx | 2 +- .../src/components/fields/SchemaField.tsx | 6 +- .../templates/ObjectFieldTemplate.tsx | 4 +- .../templates/WrapIfAdditionalTemplate.tsx | 8 +- packages/core/test/Form.test.jsx | 4 - .../templates/FieldTemplate/FieldTemplate.tsx | 12 +- .../ObjectFieldTemplate.tsx | 4 +- .../WrapIfAdditionalTemplate.tsx | 21 +- .../src/FieldTemplate/FieldTemplate.tsx | 4 + .../ObjectFieldTemplate.tsx | 4 +- .../WrapIfAdditionalTemplate.tsx | 11 +- .../src/templates/ObjectFieldTemplate.tsx | 4 +- .../templates/WrapIfAdditionalTemplate.tsx | 14 +- .../mui/src/FieldTemplate/FieldTemplate.tsx | 4 + .../ObjectFieldTemplate.tsx | 4 +- .../WrapIfAdditionalTemplate.tsx | 12 +- .../ObjectFieldTemplate.tsx | 4 +- .../WrapIfAdditionalTemplate.tsx | 11 +- .../src/FieldTemplate/FieldTemplate.tsx | 4 + .../ObjectFieldTemplate.tsx | 4 +- .../WrapIfAdditionalTemplate.tsx | 10 +- .../ObjectFieldTemplate.tsx | 4 +- .../WrapIfAdditionalTemplate.tsx | 7 +- .../src/FieldTemplate/FieldTemplate.tsx | 4 + .../ObjectFieldTemplate.tsx | 4 +- .../WrapIfAdditionalTemplate.tsx | 10 +- .../utils/src/shouldRenderOptionalField.ts | 6 +- packages/utils/src/types.ts | 26 +- 39 files changed, 532 insertions(+), 380 deletions(-) diff --git a/packages/antd/src/templates/FieldTemplate/index.tsx b/packages/antd/src/templates/FieldTemplate/index.tsx index b5c5d7d779..dc935ae4b0 100644 --- a/packages/antd/src/templates/FieldTemplate/index.tsx +++ b/packages/antd/src/templates/FieldTemplate/index.tsx @@ -34,6 +34,8 @@ export default function FieldTemplate< hidden, id, label, + onRemovePropertyClick, + onKeyRenameBlur, onDropPropertyClick, onKeyChange, rawErrors, @@ -86,6 +88,8 @@ export default function FieldTemplate< disabled={disabled} id={id} label={label} + onRemovePropertyClick={onRemovePropertyClick} + onKeyRenameBlur={onKeyRenameBlur} onDropPropertyClick={onDropPropertyClick} onKeyChange={onKeyChange} readonly={readonly} diff --git a/packages/antd/src/templates/ObjectFieldTemplate/index.tsx b/packages/antd/src/templates/ObjectFieldTemplate/index.tsx index 82b9aa15b5..51c82c120e 100644 --- a/packages/antd/src/templates/ObjectFieldTemplate/index.tsx +++ b/packages/antd/src/templates/ObjectFieldTemplate/index.tsx @@ -40,7 +40,7 @@ export default function ObjectFieldTemplate< disabled, formData, fieldPathId, - onAddClick, + onAddProperty, optionalDataControl, properties, readonly, @@ -160,7 +160,7 @@ export default function ObjectFieldTemplate< id={buttonId(fieldPathId, 'add')} className='rjsf-object-property-expand' disabled={disabled || readonly} - onClick={onAddClick(schema)} + onClick={onAddProperty} uiSchema={uiSchema} registry={registry} /> diff --git a/packages/antd/src/templates/WrapIfAdditionalTemplate/index.tsx b/packages/antd/src/templates/WrapIfAdditionalTemplate/index.tsx index e5dfba9dc4..8269fea397 100644 --- a/packages/antd/src/templates/WrapIfAdditionalTemplate/index.tsx +++ b/packages/antd/src/templates/WrapIfAdditionalTemplate/index.tsx @@ -1,4 +1,3 @@ -import { FocusEvent } from 'react'; import { Col, Row, Form, Input } from 'antd'; import { ADDITIONAL_PROPERTY_FLAG, @@ -35,8 +34,8 @@ export default function WrapIfAdditionalTemplate< disabled, id, label, - onDropPropertyClick, - onKeyChange, + onRemovePropertyClick, + onKeyRenameBlur, readonly, required, registry, @@ -66,8 +65,6 @@ export default function WrapIfAdditionalTemplate< ); } - const handleBlur = ({ target }: FocusEvent) => onKeyChange(target && target.value); - // The `block` prop is not part of the `IconButtonProps` defined in the template, so put it into the uiSchema instead const uiOptions = uiSchema ? uiSchema[UI_OPTIONS_KEY] : {}; const buttonUiOptions = { @@ -97,7 +94,7 @@ export default function WrapIfAdditionalTemplate< disabled={disabled || (readonlyAsDisabled && readonly)} id={`${id}-key`} name={`${id}-key`} - onBlur={!readonly ? handleBlur : undefined} + onBlur={!readonly ? onKeyRenameBlur : undefined} style={INPUT_STYLE} type='text' /> @@ -112,7 +109,7 @@ export default function WrapIfAdditionalTemplate< id={buttonId(id, 'remove')} className='rjsf-object-property-remove' disabled={disabled || readonly} - onClick={onDropPropertyClick(label)} + onClick={onRemovePropertyClick} uiSchema={buttonUiOptions} registry={registry} /> diff --git a/packages/chakra-ui/src/FieldTemplate/FieldTemplate.tsx b/packages/chakra-ui/src/FieldTemplate/FieldTemplate.tsx index 41e6fa0a56..05411b6e4d 100644 --- a/packages/chakra-ui/src/FieldTemplate/FieldTemplate.tsx +++ b/packages/chakra-ui/src/FieldTemplate/FieldTemplate.tsx @@ -22,6 +22,8 @@ export default function FieldTemplate< displayLabel, hidden, label, + onRemovePropertyClick, + onKeyRenameBlur, onDropPropertyClick, onKeyChange, readonly, @@ -53,6 +55,8 @@ export default function FieldTemplate< disabled={disabled} id={id} label={label} + onRemovePropertyClick={onRemovePropertyClick} + onKeyRenameBlur={onKeyRenameBlur} onDropPropertyClick={onDropPropertyClick} onKeyChange={onKeyChange} readonly={readonly} diff --git a/packages/chakra-ui/src/ObjectFieldTemplate/ObjectFieldTemplate.tsx b/packages/chakra-ui/src/ObjectFieldTemplate/ObjectFieldTemplate.tsx index 6d39663644..76f98503bc 100644 --- a/packages/chakra-ui/src/ObjectFieldTemplate/ObjectFieldTemplate.tsx +++ b/packages/chakra-ui/src/ObjectFieldTemplate/ObjectFieldTemplate.tsx @@ -29,7 +29,7 @@ export default function ObjectFieldTemplate< schema, formData, optionalDataControl, - onAddClick, + onAddProperty, registry, } = props; const uiOptions = getUiOptions(uiSchema); @@ -81,7 +81,7 @@ export default function ObjectFieldTemplate< ) => onKeyChange(target.value); - return ( @@ -56,7 +53,7 @@ export default function WrapIfAdditionalTemplate< disabled={disabled || readonly} id={`${id}-key`} name={`${id}-key`} - onBlur={!readonly ? handleBlur : undefined} + onBlur={!readonly ? onKeyRenameBlur : undefined} type='text' mb={1} /> @@ -68,7 +65,7 @@ export default function WrapIfAdditionalTemplate< id={buttonId(id, 'remove')} className='rjsf-object-property-remove' disabled={disabled || readonly} - onClick={onDropPropertyClick(label)} + onClick={onRemovePropertyClick} uiSchema={uiSchema} registry={registry} /> diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 5242934eaa..dd6f04da62 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -42,6 +42,7 @@ import { DEFAULT_ID_PREFIX, GlobalFormOptions, ERRORS_KEY, + ID_KEY, } from '@rjsf/utils'; import _cloneDeep from 'lodash/cloneDeep'; import _get from 'lodash/get'; @@ -256,37 +257,71 @@ export interface FormState; - // Private /** The current list of errors for the form directly from schema validation, does NOT include `extraErrors` */ schemaValidationErrors: RJSFValidationError[]; /** The current errors, in `ErrorSchema` format, for the form directly from schema validation, does NOT include * `extraErrors` */ schemaValidationErrorSchema: ErrorSchema; + // Private /** A container used to handle custom errors provided via `onChange` */ customErrors?: ErrorSchemaBuilder; /** @description result of schemaUtils.retrieveSchema(schema, formData). This a memoized value to avoid re calculate at internal functions (getStateFromProps, onChange) */ retrievedSchema: S; /** Flag indicating whether the initial form defaults have been generated */ initialDefaultsGenerated: boolean; + /** The registry (re)computed only when props changed */ + registry: Registry; } /** The event data passed when changes have been made to the form, includes everything from the `FormState` except * the schema validation errors. An additional `status` is added when returned from `onSubmit` */ export interface IChangeEvent - extends Omit< + extends Pick< FormState, + | 'schema' + | 'uiSchema' + | 'fieldPathId' + | 'schemaUtils' + | 'formData' + | 'edit' + | 'errors' + | 'errorSchema' | 'schemaValidationErrors' | 'schemaValidationErrorSchema' - | 'retrievedSchema' - | 'customErrors' - | 'initialDefaultsGenerated' > { /** The status of the form when submitted */ status?: 'submitted'; } +/** 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: FormState, + status?: IChangeEvent['status'], +): IChangeEvent { + return { + ..._pick(state, [ + 'schema', + 'uiSchema', + 'fieldPathId', + 'schemaUtils', + 'formData', + 'edit', + 'errors', + 'errorSchema', + 'schemaValidationErrors', + 'schemaValidationErrorSchema', + ]), + ...(status !== undefined ? { status } : undefined), + }; +} + /** The definition of a pending change that will be processed in the `onChange` handler */ interface PendingChange { @@ -330,7 +365,7 @@ export default class Form< this.state = this.getStateFromProps(props, props.formData); if (this.props.onChange && !deepEquals(this.state.formData, this.props.formData)) { - this.props.onChange(this.state); + this.props.onChange(toIChangeEvent(this.state)); } this.formElement = createRef(); } @@ -413,7 +448,7 @@ export default class Form< !deepEquals(nextState.formData, prevState.formData) && this.props.onChange ) { - this.props.onChange(nextState); + this.props.onChange(toIChangeEvent(nextState)); } this.setState(nextState); } @@ -545,7 +580,14 @@ export default class Form< errorSchema = mergedErrors.errorSchema; } - const fieldPathId = toFieldPathId('', this.getGlobalFormOptions(this.props)); + // 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; + // 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: FormState = { schemaUtils, schema: rootSchema, @@ -559,6 +601,7 @@ export default class Form< schemaValidationErrorSchema, retrievedSchema: _retrievedSchema, initialDefaultsGenerated: true, + registry, }; return nextState; } @@ -867,7 +910,7 @@ export default class Form< } this.setState(state as FormState, () => { if (onChange) { - onChange({ ...this.state, ...state }, id); + onChange(toIChangeEvent({ ...this.state, ...state }), id); } // Now remove the change we just completed and call this again this.pendingChanges.shift(); @@ -909,7 +952,7 @@ export default class Form< customErrors: undefined, } as FormState; - this.setState(state, () => onChange && onChange({ ...this.state, ...state })); + 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 @@ -975,7 +1018,7 @@ export default class Form< }, () => { if (onSubmit) { - onSubmit({ ...this.state, formData: newFormData, status: 'submitted' }, event); + onSubmit(toIChangeEvent({ ...this.state, formData: newFormData }, 'submitted'), event); } }, ); @@ -1000,28 +1043,27 @@ export default class Form< return { idPrefix: rootFieldId || idPrefix, idSeparator, experimental_componentUpdateStrategy }; } - /** Returns the registry for the form */ - getRegistry(): Registry { - const { translateString: customTranslateString, uiSchema = {} } = this.props; - const { schema, schemaUtils } = this.state; + /** Computed the registry for the form using the given `props`, `schema` and `schemaUtils` */ + getRegistry(props: FormProps, schema: S, schemaUtils: SchemaUtilsType): Registry { + const { translateString: customTranslateString, uiSchema = {} } = props; const { fields, templates, widgets, formContext, translateString } = getDefaultRegistry(); return { - fields: { ...fields, ...this.props.fields }, + fields: { ...fields, ...props.fields }, templates: { ...templates, - ...this.props.templates, + ...props.templates, ButtonTemplates: { ...templates.ButtonTemplates, - ...this.props.templates?.ButtonTemplates, + ...props.templates?.ButtonTemplates, }, }, - widgets: { ...widgets, ...this.props.widgets }, + widgets: { ...widgets, ...props.widgets }, rootSchema: schema, - formContext: this.props.formContext || formContext, + formContext: props.formContext || formContext, schemaUtils, translateString: customTranslateString || translateString, globalUiOptions: uiSchema[UI_GLOBAL_OPTIONS_KEY], - globalFormOptions: this.getGlobalFormOptions(this.props), + globalFormOptions: this.getGlobalFormOptions(props), }; } @@ -1162,8 +1204,7 @@ export default class Form< _internalFormWrapper, } = this.props; - const { schema, uiSchema, formData, errorSchema, fieldPathId } = this.state; - const registry = this.getRegistry(); + 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 diff --git a/packages/core/src/components/fields/ArrayField.tsx b/packages/core/src/components/fields/ArrayField.tsx index e508476293..4379d13bcf 100644 --- a/packages/core/src/components/fields/ArrayField.tsx +++ b/packages/core/src/components/fields/ArrayField.tsx @@ -519,8 +519,8 @@ class ArrayField(registry, schema, required, uiSchema); + const hasFormData = isFormDataAvailable(this.props.formData); const canAdd = this.canAddItem(formData) && (!renderOptionalField || hasFormData); const actualFormData = hasFormData ? keyedFormData : []; const extraClass = renderOptionalField ? ' rjsf-optional-array-field' : ''; @@ -752,8 +752,8 @@ class ArrayField(uiSchema); const { schemaUtils, fields, formContext, globalFormOptions } = registry; const { OptionalDataControlsField } = fields; - const renderOptionalField = shouldRenderOptionalField(registry, schema, required, uiSchema); - const hasFormData = isFormDataAvailable(formData); + const renderOptionalField = shouldRenderOptionalField(registry, schema, required, uiSchema); + const hasFormData = isFormDataAvailable(formData); const _schemaItems: S[] = isObject(schema.items) ? (schema.items as S[]) : ([] as S[]); const itemSchemas = _schemaItems.map((item: S, index: number) => schemaUtils.retrieveSchema(item, items[index] as unknown as T[]), diff --git a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx index b0e4b5c54e..c2ecb0fa00 100644 --- a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx +++ b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx @@ -195,6 +195,8 @@ export default function LayoutMultiSchemaField< displayLabel={displayLabel} errors={errors} onChange={onChange} + onKeyRenameBlur={noop} + onRemovePropertyClick={noop} onDropPropertyClick={ignored} onKeyChange={ignored} > diff --git a/packages/core/src/components/fields/MultiSchemaField.tsx b/packages/core/src/components/fields/MultiSchemaField.tsx index 1ffc55e959..e918eba5d4 100644 --- a/packages/core/src/components/fields/MultiSchemaField.tsx +++ b/packages/core/src/components/fields/MultiSchemaField.tsx @@ -167,7 +167,7 @@ class AnyOfField(registry, schema, required, uiSchema); - const hasFormData = isFormDataAvailable(formData); + const hasFormData = isFormDataAvailable(formData); const { selectedOption, retrievedOptions } = this.state; const { diff --git a/packages/core/src/components/fields/ObjectField.tsx b/packages/core/src/components/fields/ObjectField.tsx index 547e2e6c95..cb162037c1 100644 --- a/packages/core/src/components/fields/ObjectField.tsx +++ b/packages/core/src/components/fields/ObjectField.tsx @@ -1,7 +1,11 @@ -import { Component } from 'react'; +import { FocusEvent, useCallback, useMemo, useState } from 'react'; import { + ADDITIONAL_PROPERTY_FLAG, + ANY_OF_KEY, getTemplate, getUiOptions, + hashObject, + isFormDataAvailable, orderProperties, shouldRenderOptionalField, toFieldPathId, @@ -10,15 +14,13 @@ import { FieldProps, FormContextType, GenericObjectType, + ONE_OF_KEY, + PROPERTIES_KEY, + REF_KEY, + Registry, RJSFSchema, StrictRJSFSchema, TranslatableString, - ADDITIONAL_PROPERTY_FLAG, - PROPERTIES_KEY, - REF_KEY, - ANY_OF_KEY, - ONE_OF_KEY, - isFormDataAvailable, } from '@rjsf/utils'; import Markdown from 'markdown-to-jsx'; import get from 'lodash/get'; @@ -27,39 +29,84 @@ import isObject from 'lodash/isObject'; import set from 'lodash/set'; import unset from 'lodash/unset'; -/** Type used for the state of the `ObjectField` component */ -type ObjectFieldState = { - /** Flag indicating whether an additional property key was modified */ - wasPropertyKeyModified: boolean; - /** The set of additional properties */ - additionalProperties: object; -}; - -/** 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 +/** Returns a flag indicating whether the `name` field is required in the object schema * - * @param props - The `FieldProps` for this template + * @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 */ -class ObjectField extends Component< - FieldProps, - ObjectFieldState -> { - /** Set up the initial state */ - state = { - wasPropertyKeyModified: false, - additionalProperties: {}, - }; +function isRequired(schema: S, name: string) { + return Array.isArray(schema.required) && schema.required.indexOf(name) !== -1; +} - /** Returns a flag indicating whether the `name` field is required in the object schema - * - * @param name - The name of the field to check for required-ness - * @returns - True if the field `name` is required, false otherwise - */ - isRequired(name: string) { - const { schema } = this.props; - 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); } +} +interface ObjectFieldPropertyProps + extends Omit, 'name'> { + /** */ + propertyName: string; + /** */ + addedByAdditionalProperties: boolean; + /** */ + handleKeyRename: (oldKey: string, newKey: string) => void; + /** */ + handleRemoveProperty: (keyName: string) => void; +} + +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 = useMemo( + () => toFieldPathId(propertyName, globalFormOptions, fieldPathId.path), + [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. @@ -68,39 +115,131 @@ class ObjectField { - return (value: T | undefined, path: FieldPathList, newErrorSchema?: ErrorSchema, id?: string) => { - const { onChange } = this.props; + 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. + // 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], + ); + + /** 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; + setWasPropertyKeyModified(propertyName !== value); + handleKeyRename(propertyName, value); + }, + [handleKeyRename, propertyName], + ); + + /** 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 + * + * @deprecated in favor of `onKeyRenameBlur` + */ + const onKeyChange = useCallback( + (value: string) => { + return () => { + setWasPropertyKeyModified(propertyName !== value); + handleKeyRename(propertyName, value); + }; + }, + [propertyName, handleKeyRename], + ); + + /** The property drop/removal event handler; Called when a field is removed in an additionalProperty context + */ + const onRemovePropertyClick = useCallback(() => { + handleRemoveProperty(propertyName); + }, [propertyName, handleRemoveProperty]); /** Returns a callback to handle the onDropPropertyClick event for the given `key` which removes the old `key` data * and calls the `onChange` callback with it * * @param key - The key for which the drop callback is desired * @returns - The drop property click callback + * @deprecated in favor of `onRemovePropertyClick` */ - onDropPropertyClick = (key: string) => { - return (event: DragEvent) => { - event.preventDefault(); - const { onChange, formData, fieldPathId } = this.props; - const copiedFormData = { ...formData } as T; - unset(copiedFormData, key); - // drop property will pass the name in `path` array - onChange(copiedFormData, fieldPathId.path); - }; - }; + const onDropPropertyClick = useCallback( + (key: string) => { + return () => handleRemoveProperty(key); + }, + [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 schema: S = schemaUtils.retrieveSchema(rawSchema, formData, true); + const uiOptions = getUiOptions(uiSchema, globalUiOptions); + const { properties: schemaProperties = {} } = schema; + const formDataHash = hashObject(formData || {}); + + 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. @@ -109,86 +248,30 @@ class ObjectField { - const { uiSchema, registry } = this.props; - const { duplicateKeySuffixSeparator = '-' } = getUiOptions(uiSchema, registry.globalUiOptions); - - let index = 0; - let newKey = preferredKey; - while (has(formData, newKey)) { - newKey = `${preferredKey}${duplicateKeySuffixSeparator}${++index}`; - } - return newKey; - }; + const getAvailableKey = useCallback( + (preferredKey: string, formData?: T) => { + const { duplicateKeySuffixSeparator = '-' } = getUiOptions(uiSchema, globalUiOptions); - /** 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 oldValue - The old value of a field - * @returns - The key change callback function - */ - onKeyChange = (oldValue: any) => { - return (value: any) => { - if (oldValue === value) { - return; + let index = 0; + let newKey = preferredKey; + while (has(formData, newKey)) { + newKey = `${preferredKey}${duplicateKeySuffixSeparator}${++index}`; } - const { formData, onChange, fieldPathId } = this.props; - - value = this.getAvailableKey(value, formData); - const newFormData: GenericObjectType = { - ...(formData as GenericObjectType), - }; - const newKeys: GenericObjectType = { [oldValue]: value }; - const keyValues = Object.keys(newFormData).map((key) => { - const newKey = newKeys[key] || key; - return { [newKey]: newFormData[key] }; - }); - const renamedObj = Object.assign({}, ...keyValues); - - this.setState({ wasPropertyKeyModified: true }); - - onChange(renamedObj, fieldPathId.path); - }; - }; - - /** Returns a default value to be used for a new additional schema property of the given `type` - * - * @param type - The type of the new additional schema property - */ - getDefaultValue(type?: RJSFSchema['type']) { - const { - registry: { translateString }, - } = this.props; - 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); - } - } + 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. - * - * @param schema - The schema element to which the new property is being added */ - handleAddClick = (schema: S) => () => { + const onAddProperty = useCallback(() => { if (!(schema.additionalProperties || schema.patternProperties)) { return; } - const { formData, onChange, registry, fieldPathId } = this.props; + const { translateString } = registry; const newFormData = { ...formData } as T; - const newKey = this.getAvailableKey('newKey', newFormData); + const newKey = getAvailableKey('newKey', newFormData); if (schema.patternProperties) { // Cast this to make the `set` work properly set(newFormData as GenericObjectType, newKey, null); @@ -203,7 +286,7 @@ class ObjectField(translateString, type); // Cast this to make the `set` work properly set(newFormData as GenericObjectType, newKey, newValue); } - // add will pass the name in `path` array onChange(newFormData, fieldPathId.path); - }; + }, [formData, onChange, registry, fieldPathId, getAvailableKey, schema]); - /** Renders the `ObjectField` from the given props + /** 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 */ - render() { - const { - schema: rawSchema, - uiSchema = {}, - formData, - errorSchema, - fieldPathId, - name, - required = false, - disabled, - readonly, - hideError, - onBlur, - onFocus, - registry, - title, - } = this.props; - - const { fields, schemaUtils, translateString, globalFormOptions, globalUiOptions } = registry; - const { OptionalDataControlsField, SchemaField } = fields; - const schema: S = schemaUtils.retrieveSchema(rawSchema, formData, true); - const uiOptions = getUiOptions(uiSchema, globalUiOptions); - const { properties: schemaProperties = {} } = schema; + const handleKeyRename = useCallback( + (oldKey: string, newKey: string) => { + if (oldKey !== newKey) { + const actualNewKey = getAvailableKey(newKey, formData); + const newFormData: GenericObjectType = { + ...(formData 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); - 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[] = []; - 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)}
-
- ); + onChange(renamedObj, fieldPathId.path); } - } + }, + [formData, onChange, fieldPathId, getAvailableKey], + ); - const Template = getTemplate<'ObjectFieldTemplate', T, S, F>('ObjectFieldTemplate', registry, uiOptions); - const optionalDataControl = renderOptionalField ? ( - - ) : undefined; + /** Handles the remove click which removes the old `key` data and calls the `onChange` callback with it + */ + const handleRemoveProperty = useCallback( + (key: string) => { + const copiedFormData = { ...formData } as T; + unset(copiedFormData, key); + onChange(copiedFormData, fieldPathId.path); + }, + [onChange, fieldPathId, formData], + ); - 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 innerFieldIdPathId = toFieldPathId(name, globalFormOptions, fieldPathId); + /** 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. + * + * @param schema - The schema element to which the new property is being added + */ + const handleAddClick = useCallback(() => onAddProperty, [onAddProperty]); - 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