diff --git a/CHANGELOG.md b/CHANGELOG.md index 9498a73675..847ce604be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,27 +20,61 @@ should change the heading of the (upcoming) version to include a major version b ## @rjsf/antd - Updated most of the widgets to get `formContext` from the `registry` instead of the `props` since it will no longer be passed +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones + +## @rjsf/chakra-ui + +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones ## @rjsf/core - Updated `MultiSchemaField` and `SchemaField` to properly display `anyOf`/`oneOf` optional data fields by hiding the label and selector control when it is an optional field AND there is no form data - Updated `ArrayField`, `BooleanField`, `LayoutMultiSchemaField`, `MultiSchemaField`, `ObjectField`, `SchemaField`, `StringField` and `BaseInputTemplate` to remove `formContext` from the props +- Updated `ObjectField` to refactor the code from a class component to two stateless functional components, replacing the 3 generator-props with the 4 memoized props mentioned in the `@rjsf/utils` changes +- Updated `Form` to "memoize" the `fieldPathId` and `registry` into the `FormState`, adding a `toIChangeEvent()` helper to restrict the state returned on the `IChangeEvent` interface callbacks +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones ## @rjsf/daisyui - Updated the test mocks to remove `formContext` for the widget mock +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones + +## @rjsf/fluentui-rc + +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones + +## @rjsf/mantine + +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones ## @rjsf/mui - Updated `BaseInputTemplate` and `SelectWidget` to remove `formContext` from the props +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones ## @rjsf/primereact - Updated `SelectWidget` to remove `formContext` from the props +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones + +## @rjsf/react-bootstrap + +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones + +## @rjsf/semantic-ui + +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones ## @rjsf/shadcn - Updated the test mocks to remove `formContext` for the widget mock and added `globalFormOptions` in the registry mock +- Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones + +## @rjsf/utils + +- BREAKING CHANGE: Updated `FieldTemplateProps` and `WrapIfAdditionalTemplateProps` to replace the `onKeyChange()` and `onDropPropertyClick()` callback generator props with the `onKeyRename()`, `onKeyRenameBlur()` and `onRemoveProperty()` callback props +- BREAKING CHANGE: Updated `ObjectFieldTemplateProps` to replace the `onAddClick()` callback generator prop with the `onAddProperty()` callback prop +- Added new hook `useDeepCompareMemo()` and its associated tests ## Dev / docs / playground - Updated the `formTests.tsx` snapshots to add an `anyOf` of all arrays with different item types and removed the disabling of the optional data controls feature for the optional object with oneOfs @@ -49,6 +83,9 @@ should change the heading of the (upcoming) version to include a major version b - Updated the `Sample` and `LiveSettings` types to support the `liveSettings` inside of a sample - Updated the `Playground`'s `onSampleSelected` callback to merge any `liveSettings` in the sample on top of those already used in the playground - Updated the `customFieldAnyOf` sample to switch `IdSchema` to `FieldPathId` +- Updated the `custom-templates.md` documentation to reflect the `additionalProperties`-based interface props replacement +- Updated the `utility-functions.mf` documenation to add the new `useDeepCompareMemo()` hook +- Updated the `v6.x upgrade guide.md` documentation to add the BREAKING CHANGES to the `FieldTemplateProps`, `ObjectFieldTemplateProps` and `WrapIfAdditionalTemplateProps` interface props changes and the `useDeepCompareMemo()` hook # 6.0.0-beta.21 diff --git a/packages/antd/src/templates/FieldTemplate/index.tsx b/packages/antd/src/templates/FieldTemplate/index.tsx index b5c5d7d779..65ccf87a8c 100644 --- a/packages/antd/src/templates/FieldTemplate/index.tsx +++ b/packages/antd/src/templates/FieldTemplate/index.tsx @@ -34,8 +34,9 @@ export default function FieldTemplate< hidden, id, label, - onDropPropertyClick, - onKeyChange, + onKeyRename, + onKeyRenameBlur, + onRemoveProperty, rawErrors, rawDescription, rawHelp, @@ -86,8 +87,9 @@ export default function FieldTemplate< disabled={disabled} id={id} label={label} - onDropPropertyClick={onDropPropertyClick} - onKeyChange={onKeyChange} + onKeyRename={onKeyRename} + onKeyRenameBlur={onKeyRenameBlur} + onRemoveProperty={onRemoveProperty} readonly={readonly} required={required} schema={schema} 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..60785832f9 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, + onRemoveProperty, + 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={onRemoveProperty} uiSchema={buttonUiOptions} registry={registry} /> diff --git a/packages/chakra-ui/src/FieldTemplate/FieldTemplate.tsx b/packages/chakra-ui/src/FieldTemplate/FieldTemplate.tsx index 41e6fa0a56..5222db0915 100644 --- a/packages/chakra-ui/src/FieldTemplate/FieldTemplate.tsx +++ b/packages/chakra-ui/src/FieldTemplate/FieldTemplate.tsx @@ -22,8 +22,9 @@ export default function FieldTemplate< displayLabel, hidden, label, - onDropPropertyClick, - onKeyChange, + onKeyRename, + onKeyRenameBlur, + onRemoveProperty, readonly, registry, required, @@ -53,8 +54,9 @@ export default function FieldTemplate< disabled={disabled} id={id} label={label} - onDropPropertyClick={onDropPropertyClick} - onKeyChange={onKeyChange} + onKeyRename={onKeyRename} + onKeyRenameBlur={onKeyRenameBlur} + onRemoveProperty={onRemoveProperty} readonly={readonly} required={required} schema={schema} 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={onRemoveProperty} uiSchema={uiSchema} registry={registry} /> diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 5242934eaa..c600f99415 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'; @@ -269,24 +270,38 @@ export interface FormState; } /** 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, - | 'schemaValidationErrors' - | 'schemaValidationErrorSchema' - | 'retrievedSchema' - | 'customErrors' - | 'initialDefaultsGenerated' + 'schema' | 'uiSchema' | 'fieldPathId' | 'schemaUtils' | 'formData' | 'edit' | 'errors' | 'errorSchema' > { /** 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']), + ...(status !== undefined && { status }), + }; +} + /** The definition of a pending change that will be processed in the `onChange` handler */ interface PendingChange { @@ -330,7 +345,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 +428,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 +560,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 +581,7 @@ export default class Form< schemaValidationErrorSchema, retrievedSchema: _retrievedSchema, initialDefaultsGenerated: true, + registry, }; return nextState; } @@ -867,7 +890,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 +932,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 +998,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 +1023,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 +1184,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..3a089fd2b0 100644 --- a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx +++ b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx @@ -180,7 +180,6 @@ export default function LayoutMultiSchemaField< !hideFieldError && rawErrors.length > 0 ? ( ) : undefined; - const ignored = (value: string) => noop; return ( (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..f6faa7571a 100644 --- a/packages/core/src/components/fields/ObjectField.tsx +++ b/packages/core/src/components/fields/ObjectField.tsx @@ -1,24 +1,28 @@ -import { Component } from 'react'; +import { FocusEvent, useCallback, useState } from 'react'; import { + ADDITIONAL_PROPERTY_FLAG, + ANY_OF_KEY, getTemplate, getUiOptions, + hashObject, + isFormDataAvailable, orderProperties, shouldRenderOptionalField, toFieldPathId, + useDeepCompareMemo, ErrorSchema, + FieldPathId, FieldPathList, 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,38 +31,86 @@ 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); } +} + +/** Props for the `ObjectFieldProperty` component */ +interface ObjectFieldPropertyProps + 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 @@ -68,127 +120,145 @@ 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 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 + /** 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 */ - 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 onKeyRename = useCallback( + (value: string) => { + if (propertyName !== value) { + setWasPropertyKeyModified(true); + } + handleKeyRename(propertyName, value); + }, + [propertyName, handleKeyRename], + ); - /** 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` + /** Returns a callback the handle the blur event, getting the value from the target and passing that along to the + * `handleKeyChange` function */ - getAvailableKey = (preferredKey: string, formData?: T) => { - 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 onKeyRenameBlur = useCallback( + (event: FocusEvent) => { + const { + target: { value }, + } = event; + onKeyRename(value); + }, + [onKeyRename], + ); - /** 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 + /** The property drop/removal event handler; Called when a field is removed in an additionalProperty context */ - onKeyChange = (oldValue: any) => { - return (value: any) => { - if (oldValue === value) { - return; - } - const { formData, onChange, fieldPathId } = this.props; + const onRemoveProperty = useCallback(() => { + handleRemoveProperty(propertyName); + }, [propertyName, handleRemoveProperty]); - 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); + return ( + + ); +} - this.setState({ wasPropertyKeyModified: true }); +/** 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 || {}); - onChange(renamedObj, fieldPathId.path); - }; - }; + 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[] = []; - /** Returns a default value to be used for a new additional schema property of the given `type` + /** 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 type - The type of the new additional schema property + * @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` */ - 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); - } - } + 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. - * - * @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 +273,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; - - 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 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], + ); - 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